signalk-race-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/index.js +1133 -0
- package/package.json +18 -0
- package/public/app.js +1540 -0
- package/public/index.html +114 -0
- package/public/style.css +535 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const API = '/plugins/race-control';
|
|
3
|
+
const SK_API = '/signalk/v1/api';
|
|
4
|
+
|
|
5
|
+
let races = []; // race summaries from GET /races
|
|
6
|
+
let activeRaceId = null;
|
|
7
|
+
let raceState = null; // full race object for activeRaceId, or null
|
|
8
|
+
let vesselSuggestions = []; // [{name, mmsi}] from AIS/self, for the add-boat autocomplete
|
|
9
|
+
let boatRegistry = []; // [{name, mmsi}] cross-race memory persisted server-side
|
|
10
|
+
let nameSuggestionPool = []; // [{name, mmsi}] combined VET-register + registry + vessel names
|
|
11
|
+
let suggestionItems = []; // currently-shown filtered suggestions
|
|
12
|
+
let suggestionActiveIndex = -1;
|
|
13
|
+
let handicapBoats = [];
|
|
14
|
+
let handicapVersion = 0; // bumped each successful VET-register load
|
|
15
|
+
let vetEnabled = false; // plugin config setting (Server -> Plugin Config), read-only here; off by default
|
|
16
|
+
let startLineRefs = null; // [pointRefs, pointRefs] or null
|
|
17
|
+
let finishLineRefs = null;
|
|
18
|
+
let markRefs = []; // [pointRefs, ...]
|
|
19
|
+
let lastCourseFormRaceId = undefined; // tracks which race the course form reflects
|
|
20
|
+
let replayLive = true;
|
|
21
|
+
let replayTime = Date.now();
|
|
22
|
+
|
|
23
|
+
const raceSelect = document.getElementById('raceSelect');
|
|
24
|
+
const newRaceBtn = document.getElementById('newRaceBtn');
|
|
25
|
+
const deleteRaceBtn = document.getElementById('deleteRaceBtn');
|
|
26
|
+
const exportBtn = document.getElementById('exportBtn');
|
|
27
|
+
const newRaceForm = document.getElementById('newRaceForm');
|
|
28
|
+
const newRaceNameInput = document.getElementById('newRaceName');
|
|
29
|
+
const createRaceBtn = document.getElementById('createRaceBtn');
|
|
30
|
+
const cancelNewRaceBtn = document.getElementById('cancelNewRaceBtn');
|
|
31
|
+
const raceArea = document.getElementById('raceArea');
|
|
32
|
+
const clockEl = document.getElementById('clock');
|
|
33
|
+
const startBtn = document.getElementById('startBtn');
|
|
34
|
+
const stopBtn = document.getElementById('stopBtn');
|
|
35
|
+
const resumeBtn = document.getElementById('resumeBtn');
|
|
36
|
+
const resetBtn = document.getElementById('resetBtn');
|
|
37
|
+
const scheduleInput = document.getElementById('scheduleInput');
|
|
38
|
+
const scheduleBtn = document.getElementById('scheduleBtn');
|
|
39
|
+
const cancelScheduleBtn = document.getElementById('cancelScheduleBtn');
|
|
40
|
+
const callOffInput = document.getElementById('callOffInput');
|
|
41
|
+
const scheduleCallOffBtn = document.getElementById('scheduleCallOffBtn');
|
|
42
|
+
const cancelCallOffBtn = document.getElementById('cancelCallOffBtn');
|
|
43
|
+
const statusEl = document.getElementById('statusLine');
|
|
44
|
+
const vetStatusLine = document.getElementById('vetStatusLine');
|
|
45
|
+
const vetStatusText = document.getElementById('vetStatusText');
|
|
46
|
+
const vetRefreshBtn = document.getElementById('vetRefreshBtn');
|
|
47
|
+
const vetAlternativesTh = document.getElementById('vetAlternativesTh');
|
|
48
|
+
const addBoatRow = document.getElementById('addBoatRow');
|
|
49
|
+
const addBoatName = document.getElementById('addBoatName');
|
|
50
|
+
const addBoatBtn = document.getElementById('addBoatBtn');
|
|
51
|
+
const boatsTable = document.getElementById('boatsTable');
|
|
52
|
+
const boatsBody = document.getElementById('boatsBody');
|
|
53
|
+
const emptyMsg = document.getElementById('emptyMsg');
|
|
54
|
+
const noRacesMsg = document.getElementById('noRacesMsg');
|
|
55
|
+
const addBoatSuggestions = document.getElementById('addBoatSuggestions');
|
|
56
|
+
const courseSection = document.getElementById('courseSection');
|
|
57
|
+
const courseToggleBtn = document.getElementById('courseToggleBtn');
|
|
58
|
+
const courseBody = document.getElementById('courseBody');
|
|
59
|
+
const startLineRowsEl = document.getElementById('startLineRows');
|
|
60
|
+
const finishLineRowsEl = document.getElementById('finishLineRows');
|
|
61
|
+
const marksRowsEl = document.getElementById('marksRows');
|
|
62
|
+
const addMarkBtn = document.getElementById('addMarkBtn');
|
|
63
|
+
const saveCourseBtn = document.getElementById('saveCourseBtn');
|
|
64
|
+
const courseStatusText = document.getElementById('courseStatusText');
|
|
65
|
+
const courseChart = document.getElementById('courseChart');
|
|
66
|
+
const replayControls = document.getElementById('replayControls');
|
|
67
|
+
const replaySlider = document.getElementById('replaySlider');
|
|
68
|
+
const replayTimeLabel = document.getElementById('replayTimeLabel');
|
|
69
|
+
const replayLiveBtn = document.getElementById('replayLiveBtn');
|
|
70
|
+
|
|
71
|
+
function unwrapValue(x) {
|
|
72
|
+
if (x && typeof x === 'object' && 'value' in x) return x.value;
|
|
73
|
+
return x;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fmtDuration(ms) {
|
|
77
|
+
if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
|
|
78
|
+
const totalSec = Math.floor(ms / 1000);
|
|
79
|
+
const h = Math.floor(totalSec / 3600);
|
|
80
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
81
|
+
const s = totalSec % 60;
|
|
82
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
83
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Same as fmtDuration but signed, for "vs self" comparisons where a
|
|
87
|
+
// negative value (behind) is a normal, meaningful result rather than
|
|
88
|
+
// "not available yet".
|
|
89
|
+
function formatSignedDuration(ms) {
|
|
90
|
+
if (ms == null || !isFinite(ms)) return '—';
|
|
91
|
+
const totalSec = Math.floor(Math.abs(ms) / 1000);
|
|
92
|
+
const h = Math.floor(totalSec / 3600);
|
|
93
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
94
|
+
const s = totalSec % 60;
|
|
95
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
96
|
+
return (ms < 0 ? '-' : '+') + `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The race clock's "now" — frozen at stopTime while a race is stopped, so
|
|
100
|
+
// elapsed/corrected times stop advancing without needing every call site
|
|
101
|
+
// to know about the stop state.
|
|
102
|
+
function raceNow() {
|
|
103
|
+
return (raceState && raceState.stopTime) || Date.now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pad2(n) {
|
|
107
|
+
return String(n).padStart(2, '0');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// HH:MM:SS of the given timestamp, for the per-boat finish <input type=time>.
|
|
111
|
+
function tsToTimeInputValue(ts) {
|
|
112
|
+
if (!ts) return '';
|
|
113
|
+
const d = new Date(ts);
|
|
114
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Combines a "HH:MM:SS" wall-clock value with the race's start date to get
|
|
118
|
+
// an absolute timestamp, rolling over to the next day if the entered time
|
|
119
|
+
// is earlier than the start time (so overnight races finish correctly).
|
|
120
|
+
function timeInputValueToTs(value) {
|
|
121
|
+
if (!value) return null;
|
|
122
|
+
const parts = value.split(':').map(Number);
|
|
123
|
+
const [h, m, s] = [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
124
|
+
const base = raceState && raceState.startTime ? new Date(raceState.startTime) : new Date();
|
|
125
|
+
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, m, s, 0);
|
|
126
|
+
let ts = d.getTime();
|
|
127
|
+
if (raceState && raceState.startTime && ts < raceState.startTime) {
|
|
128
|
+
ts += 24 * 3600 * 1000;
|
|
129
|
+
}
|
|
130
|
+
return ts;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function fetchJSON(url, opts) {
|
|
134
|
+
const res = await fetch(url, opts);
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
let msg = res.statusText;
|
|
137
|
+
try {
|
|
138
|
+
const j = await res.json();
|
|
139
|
+
if (j && j.error) msg = j.error;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
const err = new Error(msg);
|
|
144
|
+
err.status = res.status;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
if (res.status === 204) return null;
|
|
148
|
+
return res.json();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setStatus(msg, isError) {
|
|
152
|
+
statusEl.textContent = msg || '';
|
|
153
|
+
statusEl.classList.toggle('error', !!isError);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setVetStatus(msg, isError) {
|
|
157
|
+
vetStatusText.textContent = msg || '';
|
|
158
|
+
vetStatusText.classList.toggle('error', !!isError);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Vessels are only used as autocomplete suggestions when adding a boat
|
|
162
|
+
// (name + MMSI hint) — a race's boat list is otherwise entered explicitly,
|
|
163
|
+
// not auto-populated from AIS.
|
|
164
|
+
async function loadVessels() {
|
|
165
|
+
try {
|
|
166
|
+
const all = await fetchJSON(`${SK_API}/vessels`);
|
|
167
|
+
const suggestions = [];
|
|
168
|
+
Object.keys(all || {}).forEach((context) => {
|
|
169
|
+
const v = all[context] || {};
|
|
170
|
+
const rawName = unwrapValue(v.name);
|
|
171
|
+
const rawMmsi = unwrapValue(v.mmsi);
|
|
172
|
+
if (rawName && String(rawName).trim()) {
|
|
173
|
+
suggestions.push({ name: String(rawName).trim(), mmsi: rawMmsi || '' });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
vesselSuggestions = suggestions;
|
|
177
|
+
rebuildNameSuggestionPool();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
setStatus('Could not load boat list from SignalK: ' + e.message, true);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Merges three sources into one suggestion list, keyed by name: the VET
|
|
184
|
+
// register (names only), the cross-race boat registry (name + remembered
|
|
185
|
+
// MMSI), and live AIS/self vessels (name + current MMSI, freshest so it
|
|
186
|
+
// wins last). Later sources overwrite an earlier entry's MMSI when they
|
|
187
|
+
// have one, but never blank out an MMSI an earlier source already found.
|
|
188
|
+
function rebuildNameSuggestionPool() {
|
|
189
|
+
const byName = new Map();
|
|
190
|
+
function upsert(name, mmsi) {
|
|
191
|
+
const key = name.toLowerCase();
|
|
192
|
+
const existing = byName.get(key);
|
|
193
|
+
if (existing) {
|
|
194
|
+
if (mmsi) existing.mmsi = mmsi;
|
|
195
|
+
} else {
|
|
196
|
+
byName.set(key, { name, mmsi: mmsi || '' });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (vetEnabled) handicapBoats.forEach((b) => upsert(b.name, ''));
|
|
200
|
+
boatRegistry.forEach((b) => upsert(b.name, b.mmsi));
|
|
201
|
+
vesselSuggestions.forEach((v) => upsert(v.name, v.mmsi));
|
|
202
|
+
nameSuggestionPool = Array.from(byName.values());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadBoatRegistry() {
|
|
206
|
+
try {
|
|
207
|
+
const data = await fetchJSON(`${API}/boat-registry`);
|
|
208
|
+
boatRegistry = data.boats || [];
|
|
209
|
+
rebuildNameSuggestionPool();
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// Non-fatal — MMSI auto-fill from history just won't be available.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findMmsiByName(name) {
|
|
216
|
+
const n = name.trim().toLowerCase();
|
|
217
|
+
const hit = nameSuggestionPool.find((s) => s.name.toLowerCase() === n && s.mmsi);
|
|
218
|
+
return hit ? hit.mmsi : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Custom dropdown instead of a native <datalist>, because datalist only
|
|
222
|
+
// matches from the start of the value in most browsers — this matches
|
|
223
|
+
// anywhere in the name (e.g. "21" or "solli" both find "RS 21 Solli").
|
|
224
|
+
function escapeHtml(s) {
|
|
225
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hideSuggestions() {
|
|
229
|
+
addBoatSuggestions.hidden = true;
|
|
230
|
+
addBoatSuggestions.innerHTML = '';
|
|
231
|
+
suggestionItems = [];
|
|
232
|
+
suggestionActiveIndex = -1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderSuggestionActive() {
|
|
236
|
+
Array.from(addBoatSuggestions.children).forEach((el, i) => {
|
|
237
|
+
el.classList.toggle('active', i === suggestionActiveIndex);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function selectSuggestion(item) {
|
|
242
|
+
addBoatName.value = item.name;
|
|
243
|
+
hideSuggestions();
|
|
244
|
+
addBoatName.focus();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function showSuggestionsFor(query) {
|
|
248
|
+
const q = query.trim().toLowerCase();
|
|
249
|
+
if (!q) {
|
|
250
|
+
hideSuggestions();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const matches = nameSuggestionPool.filter((s) => s.name.toLowerCase().includes(q)).slice(0, 20);
|
|
254
|
+
suggestionItems = matches;
|
|
255
|
+
suggestionActiveIndex = -1;
|
|
256
|
+
if (!matches.length) {
|
|
257
|
+
hideSuggestions();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
addBoatSuggestions.innerHTML = '';
|
|
261
|
+
matches.forEach((item) => {
|
|
262
|
+
const div = document.createElement('div');
|
|
263
|
+
div.className = 'suggestion-item';
|
|
264
|
+
const idx = item.name.toLowerCase().indexOf(q);
|
|
265
|
+
if (idx === -1) {
|
|
266
|
+
div.textContent = item.name;
|
|
267
|
+
} else {
|
|
268
|
+
div.innerHTML =
|
|
269
|
+
escapeHtml(item.name.slice(0, idx)) +
|
|
270
|
+
'<mark>' +
|
|
271
|
+
escapeHtml(item.name.slice(idx, idx + q.length)) +
|
|
272
|
+
'</mark>' +
|
|
273
|
+
escapeHtml(item.name.slice(idx + q.length));
|
|
274
|
+
}
|
|
275
|
+
// mousedown (not click) fires before the input's blur, so the
|
|
276
|
+
// dropdown doesn't close itself out from under the click.
|
|
277
|
+
div.addEventListener('mousedown', (e) => {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
selectSuggestion(item);
|
|
280
|
+
});
|
|
281
|
+
addBoatSuggestions.appendChild(div);
|
|
282
|
+
});
|
|
283
|
+
addBoatSuggestions.hidden = false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Whether the VET-tall register is used at all is a plugin config setting
|
|
287
|
+
// (Server -> Plugin Config), not something the webapp can change — this
|
|
288
|
+
// just reads it once at startup to decide whether to fetch/offer it.
|
|
289
|
+
async function loadVetEnabled() {
|
|
290
|
+
try {
|
|
291
|
+
const data = await fetchJSON(`${API}/vet-enabled`);
|
|
292
|
+
vetEnabled = data.enabled === true;
|
|
293
|
+
} catch (e) {
|
|
294
|
+
vetEnabled = false;
|
|
295
|
+
}
|
|
296
|
+
// When disabled, every trace of VET is removed from the webapp rather
|
|
297
|
+
// than shown as a disabled/placeholder state — the status line (register
|
|
298
|
+
// status text + refresh link) and the whole "VET alternatives" table
|
|
299
|
+
// column disappear entirely.
|
|
300
|
+
vetStatusLine.hidden = !vetEnabled;
|
|
301
|
+
vetAlternativesTh.hidden = !vetEnabled;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function loadHandicapRegister(force) {
|
|
305
|
+
if (!vetEnabled) return;
|
|
306
|
+
try {
|
|
307
|
+
setVetStatus('Loading VET register…');
|
|
308
|
+
const data = await fetchJSON(`${API}/handicap-source${force ? '?refresh=true' : ''}`);
|
|
309
|
+
handicapBoats = data.boats || [];
|
|
310
|
+
handicapVersion++;
|
|
311
|
+
rebuildNameSuggestionPool();
|
|
312
|
+
setVetStatus(`VET register: ${handicapBoats.length} boats loaded.`);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
setVetStatus('Could not load VET register: ' + e.message, true);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function loadRacesList() {
|
|
319
|
+
try {
|
|
320
|
+
const data = await fetchJSON(`${API}/races`);
|
|
321
|
+
races = data.races || [];
|
|
322
|
+
if (!activeRaceId || !races.some((r) => r.id === activeRaceId)) {
|
|
323
|
+
activeRaceId = data.currentRaceId && races.some((r) => r.id === data.currentRaceId) ? data.currentRaceId : races.length ? races[races.length - 1].id : null;
|
|
324
|
+
}
|
|
325
|
+
setStatus('');
|
|
326
|
+
} catch (e) {
|
|
327
|
+
setStatus('Could not reach race-control plugin: ' + e.message, true);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function loadRaceState() {
|
|
332
|
+
if (!activeRaceId) {
|
|
333
|
+
raceState = null;
|
|
334
|
+
} else {
|
|
335
|
+
try {
|
|
336
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}`);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
raceState = null;
|
|
339
|
+
setStatus('Could not load race: ' + e.message, true);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Only rebuild the course form when we've actually switched to a
|
|
343
|
+
// different race (or none) — never on a background poll of the same
|
|
344
|
+
// race, so it can't interrupt someone mid-edit.
|
|
345
|
+
const id = raceState ? raceState.id : null;
|
|
346
|
+
if (id !== lastCourseFormRaceId) {
|
|
347
|
+
loadCourseFormFromRace();
|
|
348
|
+
lastCourseFormRaceId = id;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Native prompt()/confirm() dialogs are unreliable in embedded SignalK
|
|
353
|
+
// webviews (chartplotters, Kip, tablet browsers) and in automated
|
|
354
|
+
// testing — they can silently no-op instead of showing anything. All
|
|
355
|
+
// "are you sure" / "name it" interactions use in-page UI instead.
|
|
356
|
+
function openNewRaceForm() {
|
|
357
|
+
newRaceForm.hidden = false;
|
|
358
|
+
newRaceNameInput.value = '';
|
|
359
|
+
newRaceNameInput.focus();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function closeNewRaceForm() {
|
|
363
|
+
newRaceForm.hidden = true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function submitNewRace() {
|
|
367
|
+
const name = newRaceNameInput.value.trim();
|
|
368
|
+
if (!name) {
|
|
369
|
+
setStatus('Race name cannot be empty.', true);
|
|
370
|
+
newRaceNameInput.focus();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const data = await fetchJSON(`${API}/races`, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ name })
|
|
378
|
+
});
|
|
379
|
+
activeRaceId = data.race.id;
|
|
380
|
+
closeNewRaceForm();
|
|
381
|
+
await loadRacesList();
|
|
382
|
+
await loadRaceState();
|
|
383
|
+
render();
|
|
384
|
+
} catch (e) {
|
|
385
|
+
setStatus(e.message, true);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function performDeleteRace() {
|
|
390
|
+
if (!activeRaceId || !raceState) return;
|
|
391
|
+
try {
|
|
392
|
+
await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}`, { method: 'DELETE' });
|
|
393
|
+
activeRaceId = null;
|
|
394
|
+
await loadRacesList();
|
|
395
|
+
await loadRaceState();
|
|
396
|
+
render();
|
|
397
|
+
} catch (e) {
|
|
398
|
+
setStatus(e.message, true);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Content-Disposition: attachment on the export response means a plain
|
|
403
|
+
// top-level navigation downloads the file without actually leaving this
|
|
404
|
+
// page — no need for a temporary <a> element or window.open.
|
|
405
|
+
function exportRace() {
|
|
406
|
+
if (!activeRaceId) return;
|
|
407
|
+
const tzOffsetMinutes = new Date().getTimezoneOffset();
|
|
408
|
+
window.location.href = `${API}/races/${encodeURIComponent(activeRaceId)}/export.xlsx?tzOffsetMinutes=${tzOffsetMinutes}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Click once to arm, click again within a few seconds to confirm — an
|
|
412
|
+
// in-page substitute for window.confirm() (see note above).
|
|
413
|
+
function armConfirm(button, idleLabel, confirmLabel, onConfirm) {
|
|
414
|
+
let armed = false;
|
|
415
|
+
let timer = null;
|
|
416
|
+
function disarm() {
|
|
417
|
+
armed = false;
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
button.textContent = idleLabel;
|
|
420
|
+
button.classList.remove('confirming');
|
|
421
|
+
}
|
|
422
|
+
button.addEventListener('click', () => {
|
|
423
|
+
if (!armed) {
|
|
424
|
+
armed = true;
|
|
425
|
+
button.textContent = confirmLabel;
|
|
426
|
+
button.classList.add('confirming');
|
|
427
|
+
timer = setTimeout(disarm, 4000);
|
|
428
|
+
} else {
|
|
429
|
+
disarm();
|
|
430
|
+
onConfirm();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function selectRace(id) {
|
|
436
|
+
activeRaceId = id;
|
|
437
|
+
fetchJSON(`${API}/races/${encodeURIComponent(id)}/select`, { method: 'POST' }).catch(() => {});
|
|
438
|
+
await loadRaceState();
|
|
439
|
+
render();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function startRace() {
|
|
443
|
+
if (!activeRaceId) return;
|
|
444
|
+
try {
|
|
445
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/start`, { method: 'POST' });
|
|
446
|
+
render();
|
|
447
|
+
} catch (e) {
|
|
448
|
+
setStatus(e.message, true);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function performResetRace() {
|
|
453
|
+
if (!activeRaceId) return;
|
|
454
|
+
try {
|
|
455
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/reset`, { method: 'POST' });
|
|
456
|
+
render();
|
|
457
|
+
} catch (e) {
|
|
458
|
+
setStatus(e.message, true);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function stopRace() {
|
|
463
|
+
if (!activeRaceId) return;
|
|
464
|
+
try {
|
|
465
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/stop`, { method: 'POST' });
|
|
466
|
+
render();
|
|
467
|
+
} catch (e) {
|
|
468
|
+
setStatus(e.message, true);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function resumeRace() {
|
|
473
|
+
if (!activeRaceId) return;
|
|
474
|
+
try {
|
|
475
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/resume`, { method: 'POST' });
|
|
476
|
+
render();
|
|
477
|
+
} catch (e) {
|
|
478
|
+
setStatus(e.message, true);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function scheduleCallOff() {
|
|
483
|
+
if (!activeRaceId) return;
|
|
484
|
+
if (!callOffInput.value) {
|
|
485
|
+
setStatus('Pick a date/time to schedule the call-off.', true);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const ts = new Date(callOffInput.value).getTime();
|
|
489
|
+
if (!isFinite(ts)) {
|
|
490
|
+
setStatus('Invalid call-off time.', true);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule-call-off`, {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
headers: { 'Content-Type': 'application/json' },
|
|
497
|
+
body: JSON.stringify({ time: ts })
|
|
498
|
+
});
|
|
499
|
+
render();
|
|
500
|
+
} catch (e) {
|
|
501
|
+
setStatus(e.message, true);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function cancelCallOffSchedule() {
|
|
506
|
+
if (!activeRaceId) return;
|
|
507
|
+
try {
|
|
508
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule-call-off/cancel`, {
|
|
509
|
+
method: 'POST'
|
|
510
|
+
});
|
|
511
|
+
render();
|
|
512
|
+
} catch (e) {
|
|
513
|
+
setStatus(e.message, true);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function setDnf(boatId, dnf) {
|
|
518
|
+
if (!activeRaceId) return;
|
|
519
|
+
try {
|
|
520
|
+
const boat = await fetchJSON(
|
|
521
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/dnf`,
|
|
522
|
+
{
|
|
523
|
+
method: 'PUT',
|
|
524
|
+
headers: { 'Content-Type': 'application/json' },
|
|
525
|
+
body: JSON.stringify({ dnf })
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
raceState.boats[boatId] = boat;
|
|
529
|
+
render();
|
|
530
|
+
} catch (e) {
|
|
531
|
+
setStatus(e.message, true);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function toggleSelf(boatId) {
|
|
536
|
+
if (!activeRaceId || !raceState) return;
|
|
537
|
+
const nextSelf = raceState.selfBoatId === boatId ? null : boatId;
|
|
538
|
+
try {
|
|
539
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/self`, {
|
|
540
|
+
method: 'PUT',
|
|
541
|
+
headers: { 'Content-Type': 'application/json' },
|
|
542
|
+
body: JSON.stringify({ boatId: nextSelf })
|
|
543
|
+
});
|
|
544
|
+
render();
|
|
545
|
+
} catch (e) {
|
|
546
|
+
setStatus(e.message, true);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function scheduleRace() {
|
|
551
|
+
if (!activeRaceId) return;
|
|
552
|
+
if (!scheduleInput.value) {
|
|
553
|
+
setStatus('Pick a date/time to schedule the start.', true);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const ts = new Date(scheduleInput.value).getTime();
|
|
557
|
+
if (!isFinite(ts)) {
|
|
558
|
+
setStatus('Invalid scheduled start time.', true);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule`, {
|
|
563
|
+
method: 'POST',
|
|
564
|
+
headers: { 'Content-Type': 'application/json' },
|
|
565
|
+
body: JSON.stringify({ startTime: ts })
|
|
566
|
+
});
|
|
567
|
+
render();
|
|
568
|
+
} catch (e) {
|
|
569
|
+
setStatus(e.message, true);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cancelSchedule() {
|
|
574
|
+
if (!activeRaceId) return;
|
|
575
|
+
try {
|
|
576
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule/cancel`, {
|
|
577
|
+
method: 'POST'
|
|
578
|
+
});
|
|
579
|
+
render();
|
|
580
|
+
} catch (e) {
|
|
581
|
+
setStatus(e.message, true);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function addBoat() {
|
|
586
|
+
if (!activeRaceId) return;
|
|
587
|
+
const name = addBoatName.value.trim();
|
|
588
|
+
if (!name) {
|
|
589
|
+
setStatus('Enter a boat name to add.', true);
|
|
590
|
+
addBoatName.focus();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// MMSI is looked up silently (from AIS or a previous race's entry for
|
|
594
|
+
// this name) rather than typed at add time — it's only ever hand-edited
|
|
595
|
+
// per boat afterward, in the table row.
|
|
596
|
+
const mmsi = findMmsiByName(name);
|
|
597
|
+
try {
|
|
598
|
+
const boat = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/boats`, {
|
|
599
|
+
method: 'POST',
|
|
600
|
+
headers: { 'Content-Type': 'application/json' },
|
|
601
|
+
body: JSON.stringify({ name, mmsi })
|
|
602
|
+
});
|
|
603
|
+
raceState.boats[boat.id] = boat;
|
|
604
|
+
addBoatName.value = '';
|
|
605
|
+
addBoatName.focus();
|
|
606
|
+
render();
|
|
607
|
+
} catch (e) {
|
|
608
|
+
setStatus(e.message, true);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Idempotent: if the boat is already gone server-side (a double-click
|
|
613
|
+
// firing twice, or another device/tab removing it first — the race
|
|
614
|
+
// committee often has more than one person editing the same race), a 404
|
|
615
|
+
// here means the desired end state is already reached, not a failure.
|
|
616
|
+
async function removeBoat(boatId) {
|
|
617
|
+
if (!activeRaceId) return true;
|
|
618
|
+
try {
|
|
619
|
+
await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}`, {
|
|
620
|
+
method: 'DELETE'
|
|
621
|
+
});
|
|
622
|
+
} catch (e) {
|
|
623
|
+
if (e.status !== 404) {
|
|
624
|
+
setStatus(e.message, true);
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (raceState && raceState.boats) delete raceState.boats[boatId];
|
|
629
|
+
rows.delete(boatId);
|
|
630
|
+
render();
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function setFinishTime(boatId, ts) {
|
|
635
|
+
if (!activeRaceId) return;
|
|
636
|
+
try {
|
|
637
|
+
const boat = await fetchJSON(
|
|
638
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/finishTime`,
|
|
639
|
+
{
|
|
640
|
+
method: 'PUT',
|
|
641
|
+
headers: { 'Content-Type': 'application/json' },
|
|
642
|
+
body: JSON.stringify({ finishTime: ts })
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
raceState.boats[boatId] = boat;
|
|
646
|
+
render();
|
|
647
|
+
} catch (e) {
|
|
648
|
+
setStatus(e.message, true);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function setMmsi(boatId, mmsi) {
|
|
653
|
+
if (!activeRaceId) return;
|
|
654
|
+
try {
|
|
655
|
+
const boat = await fetchJSON(
|
|
656
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/mmsi`,
|
|
657
|
+
{
|
|
658
|
+
method: 'PUT',
|
|
659
|
+
headers: { 'Content-Type': 'application/json' },
|
|
660
|
+
body: JSON.stringify({ mmsi })
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
raceState.boats[boatId] = boat;
|
|
664
|
+
if (boat.mmsi) loadBoatRegistry();
|
|
665
|
+
} catch (e) {
|
|
666
|
+
setStatus(e.message, true);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function setTcf(boatId, tcf) {
|
|
671
|
+
if (!activeRaceId) return;
|
|
672
|
+
try {
|
|
673
|
+
const boat = await fetchJSON(
|
|
674
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/tcf`,
|
|
675
|
+
{
|
|
676
|
+
method: 'PUT',
|
|
677
|
+
headers: { 'Content-Type': 'application/json' },
|
|
678
|
+
body: JSON.stringify({ tcf })
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
raceState.boats[boatId] = boat;
|
|
682
|
+
} catch (e) {
|
|
683
|
+
setStatus(e.message, true);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---- Course editor -------------------------------------------------
|
|
688
|
+
|
|
689
|
+
function setCourseStatus(msg, isError) {
|
|
690
|
+
courseStatusText.textContent = msg || '';
|
|
691
|
+
courseStatusText.classList.toggle('error', !!isError);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function fetchSelfPosition() {
|
|
695
|
+
try {
|
|
696
|
+
const leaf = await fetchJSON(`${SK_API}/vessels/self/navigation/position`);
|
|
697
|
+
const v = leaf && leaf.value;
|
|
698
|
+
if (v && typeof v.latitude === 'number' && typeof v.longitude === 'number') {
|
|
699
|
+
return { lat: v.latitude, lon: v.longitude };
|
|
700
|
+
}
|
|
701
|
+
} catch (e) {
|
|
702
|
+
// fall through
|
|
703
|
+
}
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// One name+lat+lon(+optional "use my position") row, shared by start
|
|
708
|
+
// line, finish line, and mark entry.
|
|
709
|
+
function buildPointRow(point) {
|
|
710
|
+
const row = document.createElement('div');
|
|
711
|
+
row.className = 'course-point-row';
|
|
712
|
+
|
|
713
|
+
const nameInput = document.createElement('input');
|
|
714
|
+
nameInput.type = 'text';
|
|
715
|
+
nameInput.className = 'course-name-input';
|
|
716
|
+
nameInput.placeholder = 'Name (optional)';
|
|
717
|
+
nameInput.value = (point && point.name) || '';
|
|
718
|
+
|
|
719
|
+
const latInput = document.createElement('input');
|
|
720
|
+
latInput.type = 'number';
|
|
721
|
+
latInput.step = 'any';
|
|
722
|
+
latInput.className = 'course-coord-input';
|
|
723
|
+
latInput.placeholder = 'Lat';
|
|
724
|
+
latInput.value = point ? point.lat : '';
|
|
725
|
+
|
|
726
|
+
const lonInput = document.createElement('input');
|
|
727
|
+
lonInput.type = 'number';
|
|
728
|
+
lonInput.step = 'any';
|
|
729
|
+
lonInput.className = 'course-coord-input';
|
|
730
|
+
lonInput.placeholder = 'Lon';
|
|
731
|
+
lonInput.value = point ? point.lon : '';
|
|
732
|
+
|
|
733
|
+
const useHereBtn = document.createElement('button');
|
|
734
|
+
useHereBtn.type = 'button';
|
|
735
|
+
useHereBtn.className = 'secondary';
|
|
736
|
+
useHereBtn.textContent = 'Use my position';
|
|
737
|
+
useHereBtn.addEventListener('click', async () => {
|
|
738
|
+
const pos = await fetchSelfPosition();
|
|
739
|
+
if (pos) {
|
|
740
|
+
latInput.value = pos.lat;
|
|
741
|
+
lonInput.value = pos.lon;
|
|
742
|
+
} else {
|
|
743
|
+
setCourseStatus('Could not read a current position from SignalK.', true);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
row.append(nameInput, latInput, lonInput, useHereBtn);
|
|
748
|
+
return { row, nameInput, latInput, lonInput };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function renderMarkRows() {
|
|
752
|
+
marksRowsEl.innerHTML = '';
|
|
753
|
+
markRefs.forEach((refs, i) => {
|
|
754
|
+
refs.nameInput.placeholder = `Mark ${i + 1} name`;
|
|
755
|
+
marksRowsEl.appendChild(refs.row);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function buildMarkRow(mark) {
|
|
760
|
+
const refs = buildPointRow(mark);
|
|
761
|
+
const upBtn = document.createElement('button');
|
|
762
|
+
upBtn.type = 'button';
|
|
763
|
+
upBtn.className = 'secondary';
|
|
764
|
+
upBtn.textContent = '↑';
|
|
765
|
+
upBtn.addEventListener('click', () => {
|
|
766
|
+
const idx = markRefs.indexOf(refs);
|
|
767
|
+
if (idx > 0) {
|
|
768
|
+
[markRefs[idx - 1], markRefs[idx]] = [markRefs[idx], markRefs[idx - 1]];
|
|
769
|
+
renderMarkRows();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
const downBtn = document.createElement('button');
|
|
773
|
+
downBtn.type = 'button';
|
|
774
|
+
downBtn.className = 'secondary';
|
|
775
|
+
downBtn.textContent = '↓';
|
|
776
|
+
downBtn.addEventListener('click', () => {
|
|
777
|
+
const idx = markRefs.indexOf(refs);
|
|
778
|
+
if (idx !== -1 && idx < markRefs.length - 1) {
|
|
779
|
+
[markRefs[idx + 1], markRefs[idx]] = [markRefs[idx], markRefs[idx + 1]];
|
|
780
|
+
renderMarkRows();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
const removeBtn = document.createElement('button');
|
|
784
|
+
removeBtn.type = 'button';
|
|
785
|
+
removeBtn.className = 'secondary danger';
|
|
786
|
+
removeBtn.textContent = '×';
|
|
787
|
+
removeBtn.addEventListener('click', () => {
|
|
788
|
+
const idx = markRefs.indexOf(refs);
|
|
789
|
+
if (idx !== -1) {
|
|
790
|
+
markRefs.splice(idx, 1);
|
|
791
|
+
renderMarkRows();
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
refs.row.append(upBtn, downBtn, removeBtn);
|
|
795
|
+
return refs;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Repopulates the course form from raceState.course — only called on race
|
|
799
|
+
// switch/load and after a successful save, never on the 1s/5s polling
|
|
800
|
+
// ticks, so it can't interrupt someone mid-edit (same reasoning as the
|
|
801
|
+
// boat-row focus guards above, just at the level of "which race is this
|
|
802
|
+
// form even showing").
|
|
803
|
+
function loadCourseFormFromRace() {
|
|
804
|
+
const c = (raceState && raceState.course) || { startLine: null, marks: [], finishLine: null };
|
|
805
|
+
|
|
806
|
+
startLineRefs = [buildPointRow(c.startLine ? c.startLine[0] : null), buildPointRow(c.startLine ? c.startLine[1] : null)];
|
|
807
|
+
startLineRefs[0].nameInput.placeholder = 'Pin end name';
|
|
808
|
+
startLineRefs[1].nameInput.placeholder = 'Committee boat end name';
|
|
809
|
+
startLineRowsEl.innerHTML = '';
|
|
810
|
+
startLineRefs.forEach((r) => startLineRowsEl.appendChild(r.row));
|
|
811
|
+
|
|
812
|
+
finishLineRefs = [buildPointRow(c.finishLine ? c.finishLine[0] : null), buildPointRow(c.finishLine ? c.finishLine[1] : null)];
|
|
813
|
+
finishLineRefs[0].nameInput.placeholder = 'Pin end name';
|
|
814
|
+
finishLineRefs[1].nameInput.placeholder = 'Committee boat end name';
|
|
815
|
+
finishLineRowsEl.innerHTML = '';
|
|
816
|
+
finishLineRefs.forEach((r) => finishLineRowsEl.appendChild(r.row));
|
|
817
|
+
|
|
818
|
+
markRefs = (c.marks || []).map((m) => buildMarkRow(m));
|
|
819
|
+
renderMarkRows();
|
|
820
|
+
setCourseStatus('');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function pointFromRefs(refs) {
|
|
824
|
+
const lat = parseFloat(refs.latInput.value);
|
|
825
|
+
const lon = parseFloat(refs.lonInput.value);
|
|
826
|
+
if (!isFinite(lat) || !isFinite(lon)) return null;
|
|
827
|
+
const name = refs.nameInput.value.trim();
|
|
828
|
+
return name ? { lat, lon, name } : { lat, lon };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function saveCourse() {
|
|
832
|
+
if (!activeRaceId) return;
|
|
833
|
+
const startA = pointFromRefs(startLineRefs[0]);
|
|
834
|
+
const startB = pointFromRefs(startLineRefs[1]);
|
|
835
|
+
const finishA = pointFromRefs(finishLineRefs[0]);
|
|
836
|
+
const finishB = pointFromRefs(finishLineRefs[1]);
|
|
837
|
+
if ((startA && !startB) || (!startA && startB)) {
|
|
838
|
+
setCourseStatus('Enter both ends of the start line, or leave both blank.', true);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if ((finishA && !finishB) || (!finishA && finishB)) {
|
|
842
|
+
setCourseStatus('Enter both ends of the finish line, or leave both blank.', true);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const marks = [];
|
|
846
|
+
for (let i = 0; i < markRefs.length; i++) {
|
|
847
|
+
const p = pointFromRefs(markRefs[i]);
|
|
848
|
+
if (!p) {
|
|
849
|
+
setCourseStatus(`Mark ${i + 1} needs both a lat and a lon.`, true);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
marks.push(Object.assign({ id: `m${i}` }, p));
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/course`, {
|
|
856
|
+
method: 'PUT',
|
|
857
|
+
headers: { 'Content-Type': 'application/json' },
|
|
858
|
+
body: JSON.stringify({
|
|
859
|
+
startLine: startA && startB ? [startA, startB] : null,
|
|
860
|
+
finishLine: finishA && finishB ? [finishA, finishB] : null,
|
|
861
|
+
marks
|
|
862
|
+
})
|
|
863
|
+
});
|
|
864
|
+
setCourseStatus('Course saved.');
|
|
865
|
+
loadCourseFormFromRace();
|
|
866
|
+
renderChart();
|
|
867
|
+
} catch (e) {
|
|
868
|
+
setCourseStatus(e.message, true);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ---- Course / track chart -------------------------------------------
|
|
873
|
+
|
|
874
|
+
const CHART_PALETTE = ['#38bdf8', '#fbbf24', '#f472b6', '#a78bfa', '#34d399', '#fb923c', '#60a5fa', '#facc15'];
|
|
875
|
+
|
|
876
|
+
// Simple equirectangular fit (longitude scaled by cos(mean latitude) so
|
|
877
|
+
// the small area a race course covers looks roughly to-scale) mapped into
|
|
878
|
+
// the SVG's viewBox with padding. Re-derived on every render rather than
|
|
879
|
+
// held fixed, since the bounding box grows as tracks come in.
|
|
880
|
+
function buildProjection(points, width, height, padding) {
|
|
881
|
+
const meanLat = points.reduce((s, p) => s + p.lat, 0) / points.length;
|
|
882
|
+
const cosLat = Math.cos((meanLat * Math.PI) / 180) || 1;
|
|
883
|
+
const xs = points.map((p) => p.lon * cosLat);
|
|
884
|
+
const ys = points.map((p) => -p.lat);
|
|
885
|
+
const minX = Math.min(...xs);
|
|
886
|
+
const maxX = Math.max(...xs);
|
|
887
|
+
const minY = Math.min(...ys);
|
|
888
|
+
const maxY = Math.max(...ys);
|
|
889
|
+
const spanX = Math.max(maxX - minX, 1e-9);
|
|
890
|
+
const spanY = Math.max(maxY - minY, 1e-9);
|
|
891
|
+
const scale = Math.min((width - 2 * padding) / spanX, (height - 2 * padding) / spanY);
|
|
892
|
+
const offX = padding + (width - 2 * padding - spanX * scale) / 2;
|
|
893
|
+
const offY = padding + (height - 2 * padding - spanY * scale) / 2;
|
|
894
|
+
return (p) => ({ x: offX + (p.lon * cosLat - minX) * scale, y: offY + (-p.lat - minY) * scale });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function renderChart() {
|
|
898
|
+
if (!raceState) {
|
|
899
|
+
courseChart.innerHTML = '';
|
|
900
|
+
replayControls.hidden = true;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const course = raceState.course || { startLine: null, marks: [], finishLine: null };
|
|
904
|
+
const boatsWithTrack = Object.values(raceState.boats || {}).filter((b) => b.track && b.track.length);
|
|
905
|
+
|
|
906
|
+
const allPoints = [];
|
|
907
|
+
if (course.startLine) allPoints.push(...course.startLine);
|
|
908
|
+
if (course.finishLine) allPoints.push(...course.finishLine);
|
|
909
|
+
allPoints.push(...course.marks);
|
|
910
|
+
boatsWithTrack.forEach((b) => allPoints.push(...b.track));
|
|
911
|
+
|
|
912
|
+
if (allPoints.length < 2) {
|
|
913
|
+
courseChart.innerHTML =
|
|
914
|
+
'<text x="400" y="250" text-anchor="middle" fill="var(--muted)" font-size="14">Add a course, then start the race, to see the chart here</text>';
|
|
915
|
+
replayControls.hidden = true;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const width = 800;
|
|
920
|
+
const height = 500;
|
|
921
|
+
const proj = buildProjection(allPoints, width, height, 40);
|
|
922
|
+
const parts = [];
|
|
923
|
+
|
|
924
|
+
const coursePts = [];
|
|
925
|
+
if (course.startLine) coursePts.push({ lat: (course.startLine[0].lat + course.startLine[1].lat) / 2, lon: (course.startLine[0].lon + course.startLine[1].lon) / 2 });
|
|
926
|
+
course.marks.forEach((m) => coursePts.push(m));
|
|
927
|
+
if (course.finishLine) coursePts.push({ lat: (course.finishLine[0].lat + course.finishLine[1].lat) / 2, lon: (course.finishLine[0].lon + course.finishLine[1].lon) / 2 });
|
|
928
|
+
if (coursePts.length >= 2) {
|
|
929
|
+
const d = coursePts.map((p, i) => (i === 0 ? 'M' : 'L') + proj(p).x.toFixed(1) + ',' + proj(p).y.toFixed(1)).join(' ');
|
|
930
|
+
parts.push(`<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4,4" />`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (course.startLine) {
|
|
934
|
+
const a = proj(course.startLine[0]);
|
|
935
|
+
const b = proj(course.startLine[1]);
|
|
936
|
+
parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--good)" stroke-width="2" />`);
|
|
937
|
+
parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--good)" font-size="11" text-anchor="middle">Start</text>`);
|
|
938
|
+
}
|
|
939
|
+
if (course.finishLine) {
|
|
940
|
+
const a = proj(course.finishLine[0]);
|
|
941
|
+
const b = proj(course.finishLine[1]);
|
|
942
|
+
parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--accent)" stroke-width="2" />`);
|
|
943
|
+
parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--accent)" font-size="11" text-anchor="middle">Finish</text>`);
|
|
944
|
+
}
|
|
945
|
+
course.marks.forEach((m, i) => {
|
|
946
|
+
const p = proj(m);
|
|
947
|
+
parts.push(`<circle cx="${p.x}" cy="${p.y}" r="5" fill="var(--bg)" stroke="var(--text)" stroke-width="1.5" />`);
|
|
948
|
+
parts.push(`<text x="${p.x}" y="${p.y - 10}" fill="var(--text)" font-size="11" text-anchor="middle">${escapeHtml(m.name || String(i + 1))}</text>`);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
let minT = Infinity;
|
|
952
|
+
let maxT = -Infinity;
|
|
953
|
+
boatsWithTrack.forEach((b) => {
|
|
954
|
+
b.track.forEach((pt) => {
|
|
955
|
+
if (pt.t < minT) minT = pt.t;
|
|
956
|
+
if (pt.t > maxT) maxT = pt.t;
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
const cutoff = replayLive ? Infinity : replayTime;
|
|
960
|
+
|
|
961
|
+
boatsWithTrack.forEach((b, idx) => {
|
|
962
|
+
const color = CHART_PALETTE[idx % CHART_PALETTE.length];
|
|
963
|
+
const pts = b.track.filter((pt) => pt.t <= cutoff);
|
|
964
|
+
if (!pts.length) return;
|
|
965
|
+
const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
|
|
966
|
+
parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85" />`);
|
|
967
|
+
const last = proj(pts[pts.length - 1]);
|
|
968
|
+
parts.push(`<circle cx="${last.x}" cy="${last.y}" r="4" fill="${color}" />`);
|
|
969
|
+
parts.push(`<text x="${(last.x + 7).toFixed(1)}" y="${(last.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
courseChart.innerHTML = parts.join('');
|
|
973
|
+
|
|
974
|
+
replayControls.hidden = boatsWithTrack.length === 0 || !isFinite(minT);
|
|
975
|
+
if (!replayControls.hidden && document.activeElement !== replaySlider) {
|
|
976
|
+
replaySlider.min = String(minT);
|
|
977
|
+
replaySlider.max = String(maxT);
|
|
978
|
+
if (replayLive) {
|
|
979
|
+
replaySlider.value = String(maxT);
|
|
980
|
+
replayTimeLabel.textContent = 'Live';
|
|
981
|
+
} else {
|
|
982
|
+
replaySlider.value = String(Math.min(Math.max(replayTime, minT), maxT));
|
|
983
|
+
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function boatList() {
|
|
989
|
+
if (!raceState) return [];
|
|
990
|
+
const now = raceNow();
|
|
991
|
+
return Object.values(raceState.boats || {})
|
|
992
|
+
.map((b) => {
|
|
993
|
+
// A DNF boat is out of the race — no ticking clock, no rank (sorts
|
|
994
|
+
// to the bottom, same as any other boat with nothing to rank by).
|
|
995
|
+
const elapsedMs = !b.dnf && raceState.startTime ? (b.finishTime || now) - raceState.startTime : null;
|
|
996
|
+
const tcf = b.tcf != null ? b.tcf : 1.0;
|
|
997
|
+
const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
|
|
998
|
+
const estimate = b.estimate || null;
|
|
999
|
+
// Finished boats rank on their real corrected time; still-racing
|
|
1000
|
+
// boats rank on the projected corrected time when the server could
|
|
1001
|
+
// estimate one (course + live position/speed available), otherwise
|
|
1002
|
+
// fall back to elapsed-so-far like before.
|
|
1003
|
+
const rankMs = b.dnf ? null : b.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
|
|
1004
|
+
return {
|
|
1005
|
+
boatId: b.id,
|
|
1006
|
+
name: b.name,
|
|
1007
|
+
mmsi: b.mmsi || '',
|
|
1008
|
+
tcf,
|
|
1009
|
+
finishTime: b.finishTime,
|
|
1010
|
+
dnf: !!b.dnf,
|
|
1011
|
+
dnfPosition: b.dnfPosition || null,
|
|
1012
|
+
elapsedMs,
|
|
1013
|
+
correctedMs,
|
|
1014
|
+
estimate,
|
|
1015
|
+
rankMs
|
|
1016
|
+
};
|
|
1017
|
+
})
|
|
1018
|
+
.sort((a, b) => {
|
|
1019
|
+
if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
|
|
1020
|
+
if (a.rankMs == null) return 1;
|
|
1021
|
+
if (b.rankMs == null) return -1;
|
|
1022
|
+
return a.rankMs - b.rankMs;
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Compares every boat to whichever one is marked "self". Two shapes:
|
|
1027
|
+
// - "countdown": self is still racing and the other boat has already
|
|
1028
|
+
// finished, so the other's corrected time is a fixed target — this is
|
|
1029
|
+
// how much longer self can take (in elapsed time) and still finish
|
|
1030
|
+
// with a lower corrected time. Ticks down every render; goes negative
|
|
1031
|
+
// once self can no longer catch up even by finishing instantly.
|
|
1032
|
+
// - "gap": both values are pinned (self finished) or both still moving
|
|
1033
|
+
// (neither finished) — just the current corrected-time gap, positive
|
|
1034
|
+
// when self is ahead.
|
|
1035
|
+
function computeVsSelf(boats, selfBoatId) {
|
|
1036
|
+
const map = new Map();
|
|
1037
|
+
const self = boats.find((b) => b.boatId === selfBoatId);
|
|
1038
|
+
if (!self) return map;
|
|
1039
|
+
boats.forEach((b) => {
|
|
1040
|
+
if (b.boatId === selfBoatId) {
|
|
1041
|
+
map.set(b.boatId, { type: 'self' });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (self.dnf) {
|
|
1045
|
+
// Self is out of the race — no meaningful comparison to make.
|
|
1046
|
+
map.set(b.boatId, { type: 'none' });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (!self.finishTime && b.finishTime) {
|
|
1050
|
+
const thresholdElapsedMs = b.correctedMs / (self.tcf || 1);
|
|
1051
|
+
map.set(b.boatId, { type: 'countdown', remainingMs: thresholdElapsedMs - self.elapsedMs });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const selfVal = self.finishTime ? self.correctedMs : self.rankMs;
|
|
1055
|
+
const otherVal = b.finishTime ? b.correctedMs : b.rankMs;
|
|
1056
|
+
if (selfVal == null || otherVal == null) {
|
|
1057
|
+
map.set(b.boatId, { type: 'none' });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
map.set(b.boatId, { type: 'gap', gapMs: otherVal - selfVal });
|
|
1061
|
+
});
|
|
1062
|
+
return map;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Row DOM nodes are created once per boat and reused across renders, so an
|
|
1066
|
+
// in-progress edit (TCF, finish time) is never blown away by the
|
|
1067
|
+
// once-a-second clock tick that redraws elapsed/corrected time.
|
|
1068
|
+
const rows = new Map(); // boatId -> row refs
|
|
1069
|
+
|
|
1070
|
+
function buildRow(boatId) {
|
|
1071
|
+
const tr = document.createElement('tr');
|
|
1072
|
+
|
|
1073
|
+
const selfBtn = document.createElement('button');
|
|
1074
|
+
selfBtn.type = 'button';
|
|
1075
|
+
selfBtn.className = 'self-btn';
|
|
1076
|
+
selfBtn.title = 'Mark as self, to compare other boats against';
|
|
1077
|
+
selfBtn.textContent = '☆';
|
|
1078
|
+
selfBtn.addEventListener('click', () => toggleSelf(boatId));
|
|
1079
|
+
|
|
1080
|
+
const nameSpan = document.createElement('span');
|
|
1081
|
+
nameSpan.className = 'boat-name-text';
|
|
1082
|
+
|
|
1083
|
+
const tdName = document.createElement('td');
|
|
1084
|
+
tdName.append(selfBtn, nameSpan);
|
|
1085
|
+
|
|
1086
|
+
const mmsiInput = document.createElement('input');
|
|
1087
|
+
mmsiInput.type = 'text';
|
|
1088
|
+
mmsiInput.className = 'mmsi-input';
|
|
1089
|
+
mmsiInput.placeholder = 'MMSI';
|
|
1090
|
+
mmsiInput.addEventListener('change', () => setMmsi(boatId, mmsiInput.value.trim()));
|
|
1091
|
+
const tdMmsi = document.createElement('td');
|
|
1092
|
+
tdMmsi.appendChild(mmsiInput);
|
|
1093
|
+
|
|
1094
|
+
const tcfInput = document.createElement('input');
|
|
1095
|
+
tcfInput.type = 'number';
|
|
1096
|
+
tcfInput.step = '0.001';
|
|
1097
|
+
tcfInput.min = '0.01';
|
|
1098
|
+
tcfInput.className = 'tcf-input';
|
|
1099
|
+
tcfInput.addEventListener('change', () => {
|
|
1100
|
+
const val = parseFloat(tcfInput.value);
|
|
1101
|
+
if (isFinite(val) && val > 0) setTcf(boatId, val);
|
|
1102
|
+
});
|
|
1103
|
+
// Handicap edits must be deliberate text entry — block the number
|
|
1104
|
+
// input's scroll-wheel and up/down-arrow increment/decrement so a
|
|
1105
|
+
// stray scroll or arrow key can't silently nudge a boat's TCF.
|
|
1106
|
+
tcfInput.addEventListener(
|
|
1107
|
+
'wheel',
|
|
1108
|
+
(e) => {
|
|
1109
|
+
e.preventDefault();
|
|
1110
|
+
},
|
|
1111
|
+
{ passive: false }
|
|
1112
|
+
);
|
|
1113
|
+
tcfInput.addEventListener('keydown', (e) => {
|
|
1114
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') e.preventDefault();
|
|
1115
|
+
});
|
|
1116
|
+
const tdTcf = document.createElement('td');
|
|
1117
|
+
tdTcf.appendChild(tcfInput);
|
|
1118
|
+
|
|
1119
|
+
// Alternatives specific to this boat's name (populated once the VET
|
|
1120
|
+
// register is loaded/matched — see refreshVetAlternatives), so picking
|
|
1121
|
+
// one applies that exact number without retyping.
|
|
1122
|
+
const vetSelect = document.createElement('select');
|
|
1123
|
+
vetSelect.className = 'vet-select';
|
|
1124
|
+
vetSelect.addEventListener('change', () => {
|
|
1125
|
+
const val = parseFloat(vetSelect.value);
|
|
1126
|
+
if (isFinite(val) && val > 0) {
|
|
1127
|
+
tcfInput.value = val;
|
|
1128
|
+
setTcf(boatId, val);
|
|
1129
|
+
}
|
|
1130
|
+
// Left showing the picked alternative (synced from tcf on future
|
|
1131
|
+
// renders) rather than reset to the placeholder — see
|
|
1132
|
+
// syncVetSelectValue.
|
|
1133
|
+
});
|
|
1134
|
+
const vetBadge = document.createElement('span');
|
|
1135
|
+
vetBadge.className = 'vet-badge';
|
|
1136
|
+
const tdVet = document.createElement('td');
|
|
1137
|
+
tdVet.className = 'vet-cell';
|
|
1138
|
+
tdVet.hidden = !vetEnabled;
|
|
1139
|
+
tdVet.append(vetSelect, vetBadge);
|
|
1140
|
+
|
|
1141
|
+
const tdElapsed = document.createElement('td');
|
|
1142
|
+
const tdCorrected = document.createElement('td');
|
|
1143
|
+
const tdEstFinish = document.createElement('td');
|
|
1144
|
+
tdEstFinish.className = 'est-finish';
|
|
1145
|
+
const tdVsSelf = document.createElement('td');
|
|
1146
|
+
tdVsSelf.className = 'vs-self';
|
|
1147
|
+
|
|
1148
|
+
const finishTimeInput = document.createElement('input');
|
|
1149
|
+
finishTimeInput.type = 'time';
|
|
1150
|
+
finishTimeInput.step = '1';
|
|
1151
|
+
finishTimeInput.className = 'finish-time-input';
|
|
1152
|
+
finishTimeInput.addEventListener('change', () => {
|
|
1153
|
+
const ts = finishTimeInput.value ? timeInputValueToTs(finishTimeInput.value) : null;
|
|
1154
|
+
setFinishTime(boatId, ts);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const finishNowBtn = document.createElement('button');
|
|
1158
|
+
finishNowBtn.type = 'button';
|
|
1159
|
+
finishNowBtn.className = 'finish-now-btn';
|
|
1160
|
+
finishNowBtn.textContent = 'Now';
|
|
1161
|
+
finishNowBtn.addEventListener('click', () => setFinishTime(boatId, Date.now()));
|
|
1162
|
+
|
|
1163
|
+
const finishClearBtn = document.createElement('button');
|
|
1164
|
+
finishClearBtn.type = 'button';
|
|
1165
|
+
finishClearBtn.className = 'finish-clear-btn';
|
|
1166
|
+
finishClearBtn.textContent = 'Clear';
|
|
1167
|
+
finishClearBtn.addEventListener('click', () => setFinishTime(boatId, null));
|
|
1168
|
+
|
|
1169
|
+
const finishDnfBtn = document.createElement('button');
|
|
1170
|
+
finishDnfBtn.type = 'button';
|
|
1171
|
+
finishDnfBtn.className = 'finish-dnf-btn';
|
|
1172
|
+
finishDnfBtn.textContent = 'DNF';
|
|
1173
|
+
finishDnfBtn.addEventListener('click', () => setDnf(boatId, true));
|
|
1174
|
+
|
|
1175
|
+
const finishNormalWrap = document.createElement('div');
|
|
1176
|
+
finishNormalWrap.className = 'finish-cell';
|
|
1177
|
+
finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn);
|
|
1178
|
+
|
|
1179
|
+
const dnfTag = document.createElement('span');
|
|
1180
|
+
dnfTag.className = 'dnf-tag';
|
|
1181
|
+
dnfTag.textContent = 'DNF';
|
|
1182
|
+
const dnfPosSpan = document.createElement('span');
|
|
1183
|
+
dnfPosSpan.className = 'dnf-pos';
|
|
1184
|
+
const undoDnfBtn = document.createElement('button');
|
|
1185
|
+
undoDnfBtn.type = 'button';
|
|
1186
|
+
undoDnfBtn.className = 'undo-dnf-btn';
|
|
1187
|
+
undoDnfBtn.textContent = 'Undo DNF';
|
|
1188
|
+
undoDnfBtn.addEventListener('click', () => setDnf(boatId, false));
|
|
1189
|
+
const dnfWrap = document.createElement('div');
|
|
1190
|
+
dnfWrap.className = 'finish-cell';
|
|
1191
|
+
dnfWrap.append(dnfTag, dnfPosSpan, undoDnfBtn);
|
|
1192
|
+
|
|
1193
|
+
const tdFinish = document.createElement('td');
|
|
1194
|
+
tdFinish.append(finishNormalWrap, dnfWrap);
|
|
1195
|
+
|
|
1196
|
+
const removeBtn = document.createElement('button');
|
|
1197
|
+
removeBtn.type = 'button';
|
|
1198
|
+
removeBtn.className = 'secondary danger remove-boat-btn';
|
|
1199
|
+
removeBtn.textContent = 'Remove';
|
|
1200
|
+
armConfirm(removeBtn, 'Remove', 'Confirm?', async () => {
|
|
1201
|
+
removeBtn.disabled = true;
|
|
1202
|
+
const removed = await removeBoat(boatId);
|
|
1203
|
+
// On success the row (and this button) is gone from the DOM already;
|
|
1204
|
+
// only re-enable if it's still here, i.e. a real failure occurred.
|
|
1205
|
+
if (!removed) removeBtn.disabled = false;
|
|
1206
|
+
});
|
|
1207
|
+
const tdRemove = document.createElement('td');
|
|
1208
|
+
tdRemove.appendChild(removeBtn);
|
|
1209
|
+
|
|
1210
|
+
tr.append(tdName, tdMmsi, tdTcf, tdVet, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
|
|
1211
|
+
|
|
1212
|
+
return {
|
|
1213
|
+
tr,
|
|
1214
|
+
selfBtn,
|
|
1215
|
+
nameSpan,
|
|
1216
|
+
tdEstFinish,
|
|
1217
|
+
tdVsSelf,
|
|
1218
|
+
mmsiInput,
|
|
1219
|
+
tcfInput,
|
|
1220
|
+
vetSelect,
|
|
1221
|
+
vetBadge,
|
|
1222
|
+
tdElapsed,
|
|
1223
|
+
tdCorrected,
|
|
1224
|
+
finishNormalWrap,
|
|
1225
|
+
finishTimeInput,
|
|
1226
|
+
finishNowBtn,
|
|
1227
|
+
finishClearBtn,
|
|
1228
|
+
finishDnfBtn,
|
|
1229
|
+
dnfWrap,
|
|
1230
|
+
dnfPosSpan,
|
|
1231
|
+
vetHandicapVersion: -1
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Rebuilds a row's VET-alternatives <select> from the current register —
|
|
1236
|
+
// only called when the register itself (re)loads, not every render tick,
|
|
1237
|
+
// so an open dropdown or mid-pick isn't disrupted every second. Never
|
|
1238
|
+
// called at all while vetEnabled is false (see the call site) — the
|
|
1239
|
+
// "VET alternatives" column is hidden entirely in that case.
|
|
1240
|
+
function refreshVetAlternatives(row, boatName) {
|
|
1241
|
+
const entry = handicapBoats.find((h) => h.name.toLowerCase() === boatName.trim().toLowerCase());
|
|
1242
|
+
row.vetSelect.innerHTML = '';
|
|
1243
|
+
const placeholder = document.createElement('option');
|
|
1244
|
+
placeholder.value = '';
|
|
1245
|
+
placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
|
|
1246
|
+
row.vetSelect.appendChild(placeholder);
|
|
1247
|
+
row.vetSelect.disabled = !entry;
|
|
1248
|
+
if (entry) {
|
|
1249
|
+
entry.vets.forEach((v) => {
|
|
1250
|
+
const opt = document.createElement('option');
|
|
1251
|
+
opt.value = String(v.value);
|
|
1252
|
+
opt.textContent = `${v.label}: ${v.value}`;
|
|
1253
|
+
row.vetSelect.appendChild(opt);
|
|
1254
|
+
});
|
|
1255
|
+
const notValid = /ikke/i.test(entry.validity || '');
|
|
1256
|
+
row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
|
|
1257
|
+
row.vetBadge.classList.toggle('warn', notValid);
|
|
1258
|
+
} else {
|
|
1259
|
+
row.vetBadge.textContent = '';
|
|
1260
|
+
row.vetBadge.classList.remove('warn');
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Keeps the select showing whichever alternative is actually in effect
|
|
1265
|
+
// (matching the boat's current TCF) rather than resetting to the
|
|
1266
|
+
// placeholder after a pick — falls back to the placeholder if the TCF was
|
|
1267
|
+
// typed by hand and doesn't match any listed alternative.
|
|
1268
|
+
function syncVetSelectValue(row, tcf) {
|
|
1269
|
+
if (document.activeElement === row.vetSelect) return;
|
|
1270
|
+
const match = Array.from(row.vetSelect.options).find(
|
|
1271
|
+
(o) => o.value !== '' && Math.abs(parseFloat(o.value) - tcf) < 1e-9
|
|
1272
|
+
);
|
|
1273
|
+
row.vetSelect.value = match ? match.value : '';
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function renderRaceSelect() {
|
|
1277
|
+
const prevFocus = document.activeElement === raceSelect;
|
|
1278
|
+
raceSelect.innerHTML = '';
|
|
1279
|
+
races.forEach((r) => {
|
|
1280
|
+
const opt = document.createElement('option');
|
|
1281
|
+
opt.value = r.id;
|
|
1282
|
+
const when = r.startTime
|
|
1283
|
+
? new Date(r.startTime).toLocaleString()
|
|
1284
|
+
: r.scheduledStart
|
|
1285
|
+
? 'scheduled ' + new Date(r.scheduledStart).toLocaleString()
|
|
1286
|
+
: 'not started';
|
|
1287
|
+
opt.textContent = `${r.name} (${when})`;
|
|
1288
|
+
raceSelect.appendChild(opt);
|
|
1289
|
+
});
|
|
1290
|
+
if (activeRaceId) raceSelect.value = activeRaceId;
|
|
1291
|
+
if (prevFocus) raceSelect.focus();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function render() {
|
|
1295
|
+
renderRaceSelect();
|
|
1296
|
+
noRacesMsg.hidden = races.length > 0;
|
|
1297
|
+
raceArea.hidden = !raceState;
|
|
1298
|
+
addBoatRow.hidden = !raceState;
|
|
1299
|
+
boatsTable.hidden = !raceState;
|
|
1300
|
+
deleteRaceBtn.hidden = !raceState;
|
|
1301
|
+
exportBtn.hidden = !raceState;
|
|
1302
|
+
courseSection.hidden = !raceState;
|
|
1303
|
+
|
|
1304
|
+
if (!raceState) {
|
|
1305
|
+
emptyMsg.hidden = true;
|
|
1306
|
+
renderChart();
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
renderChart();
|
|
1311
|
+
|
|
1312
|
+
const now = Date.now();
|
|
1313
|
+
if (raceState.startTime) {
|
|
1314
|
+
clockEl.textContent = fmtDuration(raceNow() - raceState.startTime);
|
|
1315
|
+
clockEl.classList.remove('countdown');
|
|
1316
|
+
clockEl.classList.toggle('stopped', !!raceState.stopTime);
|
|
1317
|
+
if (raceState.scheduledCallOff && !raceState.stopTime && now >= raceState.scheduledCallOff) {
|
|
1318
|
+
// The scheduled call-off moment has passed locally; poke the server
|
|
1319
|
+
// so we pick up the auto-stop/DNF without waiting for the next poll.
|
|
1320
|
+
loadRaceState();
|
|
1321
|
+
}
|
|
1322
|
+
} else if (raceState.scheduledStart) {
|
|
1323
|
+
clockEl.textContent = '-' + fmtDuration(raceState.scheduledStart - now);
|
|
1324
|
+
clockEl.classList.add('countdown');
|
|
1325
|
+
clockEl.classList.remove('stopped');
|
|
1326
|
+
if (now >= raceState.scheduledStart) {
|
|
1327
|
+
// The scheduled moment has passed locally; poke the server so we
|
|
1328
|
+
// pick up the auto-start without waiting for the next 5s poll.
|
|
1329
|
+
loadRaceState();
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
clockEl.textContent = '00:00:00';
|
|
1333
|
+
clockEl.classList.remove('countdown', 'stopped');
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
startBtn.disabled = !!raceState.startTime;
|
|
1337
|
+
startBtn.textContent = raceState.startTime ? 'Race Started' : 'Start Race';
|
|
1338
|
+
stopBtn.hidden = !raceState.startTime || !!raceState.stopTime;
|
|
1339
|
+
resumeBtn.hidden = !raceState.stopTime;
|
|
1340
|
+
|
|
1341
|
+
scheduleInput.disabled = !!raceState.startTime;
|
|
1342
|
+
scheduleBtn.disabled = !!raceState.startTime;
|
|
1343
|
+
cancelScheduleBtn.hidden = !raceState.scheduledStart || !!raceState.startTime;
|
|
1344
|
+
|
|
1345
|
+
callOffInput.disabled = !!raceState.stopTime;
|
|
1346
|
+
scheduleCallOffBtn.disabled = !!raceState.stopTime;
|
|
1347
|
+
scheduleCallOffBtn.textContent = raceState.scheduledCallOff
|
|
1348
|
+
? 'Call-off: ' + new Date(raceState.scheduledCallOff).toLocaleTimeString()
|
|
1349
|
+
: 'Schedule Call-off';
|
|
1350
|
+
cancelCallOffBtn.hidden = !raceState.scheduledCallOff || !!raceState.stopTime;
|
|
1351
|
+
|
|
1352
|
+
const boats = boatList();
|
|
1353
|
+
emptyMsg.hidden = boats.length > 0;
|
|
1354
|
+
const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
|
|
1355
|
+
|
|
1356
|
+
boats.forEach((b) => {
|
|
1357
|
+
let row = rows.get(b.boatId);
|
|
1358
|
+
if (!row) {
|
|
1359
|
+
row = buildRow(b.boatId);
|
|
1360
|
+
rows.set(b.boatId, row);
|
|
1361
|
+
}
|
|
1362
|
+
// Moving an already-attached node to its sorted position keeps it
|
|
1363
|
+
// intact (focus, in-progress edits) rather than recreating it — but
|
|
1364
|
+
// the move itself still resets a text cursor and closes an open
|
|
1365
|
+
// <select>, so skip it entirely while the user has something in this
|
|
1366
|
+
// row focused. It'll snap to its correct position as soon as they're
|
|
1367
|
+
// done (next render after focus moves away).
|
|
1368
|
+
if (!row.tr.contains(document.activeElement)) {
|
|
1369
|
+
boatsBody.appendChild(row.tr);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
row.tr.classList.toggle('finished', !!b.finishTime);
|
|
1373
|
+
row.tr.classList.toggle('dnf', !!b.dnf);
|
|
1374
|
+
row.nameSpan.textContent = b.name;
|
|
1375
|
+
const isSelf = b.boatId === raceState.selfBoatId;
|
|
1376
|
+
row.selfBtn.textContent = isSelf ? '★' : '☆';
|
|
1377
|
+
row.selfBtn.classList.toggle('active', isSelf);
|
|
1378
|
+
if (document.activeElement !== row.mmsiInput) {
|
|
1379
|
+
row.mmsiInput.value = b.mmsi;
|
|
1380
|
+
}
|
|
1381
|
+
if (document.activeElement !== row.tcfInput) {
|
|
1382
|
+
row.tcfInput.value = b.tcf;
|
|
1383
|
+
}
|
|
1384
|
+
if (vetEnabled && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
1385
|
+
refreshVetAlternatives(row, b.name);
|
|
1386
|
+
row.vetHandicapVersion = handicapVersion;
|
|
1387
|
+
}
|
|
1388
|
+
syncVetSelectValue(row, b.tcf);
|
|
1389
|
+
row.tdElapsed.textContent = fmtDuration(b.elapsedMs);
|
|
1390
|
+
row.tdCorrected.textContent = fmtDuration(b.correctedMs);
|
|
1391
|
+
|
|
1392
|
+
if (b.dnf) {
|
|
1393
|
+
row.tdEstFinish.textContent = 'DNF';
|
|
1394
|
+
} else if (b.finishTime) {
|
|
1395
|
+
row.tdEstFinish.textContent = '—';
|
|
1396
|
+
} else if (b.estimate) {
|
|
1397
|
+
const t = new Date(b.estimate.estFinishTime);
|
|
1398
|
+
const marks = b.estimate.marksRemaining;
|
|
1399
|
+
const viaMarks = marks > 0 ? ` via ${marks} mark${marks === 1 ? '' : 's'}` : '';
|
|
1400
|
+
row.tdEstFinish.innerHTML =
|
|
1401
|
+
`${pad2(t.getHours())}:${pad2(t.getMinutes())}:${pad2(t.getSeconds())}` +
|
|
1402
|
+
`<span class="est-detail">~${b.estimate.remainingNm}nm${viaMarks} @ ${b.estimate.sogKn}kn</span>`;
|
|
1403
|
+
} else {
|
|
1404
|
+
row.tdEstFinish.textContent = '—';
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const vs = vsSelfMap.get(b.boatId);
|
|
1408
|
+
if (!vs || vs.type === 'none') {
|
|
1409
|
+
row.tdVsSelf.textContent = raceState.selfBoatId ? '—' : '';
|
|
1410
|
+
row.tdVsSelf.className = 'vs-self';
|
|
1411
|
+
row.tdVsSelf.title = '';
|
|
1412
|
+
} else if (vs.type === 'self') {
|
|
1413
|
+
row.tdVsSelf.innerHTML = '<span class="self-tag">SELF</span>';
|
|
1414
|
+
row.tdVsSelf.className = 'vs-self';
|
|
1415
|
+
row.tdVsSelf.title = '';
|
|
1416
|
+
} else if (vs.type === 'countdown') {
|
|
1417
|
+
const behind = vs.remainingMs < 0;
|
|
1418
|
+
row.tdVsSelf.textContent = (behind ? '-' : '') + fmtDuration(Math.abs(vs.remainingMs));
|
|
1419
|
+
row.tdVsSelf.className = 'vs-self ' + (behind ? 'behind' : 'ahead');
|
|
1420
|
+
row.tdVsSelf.title = behind
|
|
1421
|
+
? 'Self would already finish behind this boat on corrected time'
|
|
1422
|
+
: 'Time self has left to finish and still beat this boat on corrected time';
|
|
1423
|
+
} else {
|
|
1424
|
+
row.tdVsSelf.textContent = formatSignedDuration(vs.gapMs);
|
|
1425
|
+
row.tdVsSelf.className = 'vs-self ' + (vs.gapMs >= 0 ? 'ahead' : 'behind');
|
|
1426
|
+
row.tdVsSelf.title = 'Corrected-time gap to self (positive = self ahead)';
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
row.finishNormalWrap.hidden = b.dnf;
|
|
1430
|
+
row.dnfWrap.hidden = !b.dnf;
|
|
1431
|
+
if (b.dnf) {
|
|
1432
|
+
row.dnfPosSpan.textContent = b.dnfPosition
|
|
1433
|
+
? `at ${b.dnfPosition.lat.toFixed(4)}°, ${b.dnfPosition.lon.toFixed(4)}°`
|
|
1434
|
+
: 'position unknown';
|
|
1435
|
+
} else {
|
|
1436
|
+
if (document.activeElement !== row.finishTimeInput) {
|
|
1437
|
+
row.finishTimeInput.value = tsToTimeInputValue(b.finishTime);
|
|
1438
|
+
}
|
|
1439
|
+
const canFinish = !!raceState.startTime;
|
|
1440
|
+
row.finishTimeInput.disabled = !canFinish;
|
|
1441
|
+
row.finishNowBtn.disabled = !canFinish;
|
|
1442
|
+
row.finishClearBtn.disabled = !canFinish || !b.finishTime;
|
|
1443
|
+
row.finishDnfBtn.disabled = !canFinish;
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
const seen = new Set(boats.map((b) => b.boatId));
|
|
1448
|
+
rows.forEach((row, boatId) => {
|
|
1449
|
+
if (!seen.has(boatId)) {
|
|
1450
|
+
row.tr.remove();
|
|
1451
|
+
rows.delete(boatId);
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
raceSelect.addEventListener('change', () => selectRace(raceSelect.value));
|
|
1457
|
+
newRaceBtn.addEventListener('click', openNewRaceForm);
|
|
1458
|
+
cancelNewRaceBtn.addEventListener('click', closeNewRaceForm);
|
|
1459
|
+
createRaceBtn.addEventListener('click', submitNewRace);
|
|
1460
|
+
newRaceNameInput.addEventListener('keydown', (e) => {
|
|
1461
|
+
if (e.key === 'Enter') submitNewRace();
|
|
1462
|
+
if (e.key === 'Escape') closeNewRaceForm();
|
|
1463
|
+
});
|
|
1464
|
+
exportBtn.addEventListener('click', exportRace);
|
|
1465
|
+
armConfirm(deleteRaceBtn, 'Delete Race', 'Confirm Delete?', performDeleteRace);
|
|
1466
|
+
startBtn.addEventListener('click', startRace);
|
|
1467
|
+
stopBtn.addEventListener('click', stopRace);
|
|
1468
|
+
resumeBtn.addEventListener('click', resumeRace);
|
|
1469
|
+
armConfirm(resetBtn, 'Reset', 'Confirm Reset?', performResetRace);
|
|
1470
|
+
scheduleBtn.addEventListener('click', scheduleRace);
|
|
1471
|
+
cancelScheduleBtn.addEventListener('click', cancelSchedule);
|
|
1472
|
+
scheduleCallOffBtn.addEventListener('click', scheduleCallOff);
|
|
1473
|
+
cancelCallOffBtn.addEventListener('click', cancelCallOffSchedule);
|
|
1474
|
+
vetRefreshBtn.addEventListener('click', () => loadHandicapRegister(true));
|
|
1475
|
+
addBoatBtn.addEventListener('click', () => {
|
|
1476
|
+
hideSuggestions();
|
|
1477
|
+
addBoat();
|
|
1478
|
+
});
|
|
1479
|
+
addBoatName.addEventListener('input', () => showSuggestionsFor(addBoatName.value));
|
|
1480
|
+
addBoatName.addEventListener('focus', () => {
|
|
1481
|
+
if (addBoatName.value.trim()) showSuggestionsFor(addBoatName.value);
|
|
1482
|
+
});
|
|
1483
|
+
addBoatName.addEventListener('blur', () => hideSuggestions());
|
|
1484
|
+
addBoatName.addEventListener('keydown', (e) => {
|
|
1485
|
+
if (e.key === 'ArrowDown' && suggestionItems.length) {
|
|
1486
|
+
e.preventDefault();
|
|
1487
|
+
suggestionActiveIndex = (suggestionActiveIndex + 1) % suggestionItems.length;
|
|
1488
|
+
renderSuggestionActive();
|
|
1489
|
+
} else if (e.key === 'ArrowUp' && suggestionItems.length) {
|
|
1490
|
+
e.preventDefault();
|
|
1491
|
+
suggestionActiveIndex = (suggestionActiveIndex - 1 + suggestionItems.length) % suggestionItems.length;
|
|
1492
|
+
renderSuggestionActive();
|
|
1493
|
+
} else if (e.key === 'Enter') {
|
|
1494
|
+
e.preventDefault();
|
|
1495
|
+
if (suggestionActiveIndex >= 0 && suggestionItems[suggestionActiveIndex]) {
|
|
1496
|
+
selectSuggestion(suggestionItems[suggestionActiveIndex]);
|
|
1497
|
+
} else {
|
|
1498
|
+
hideSuggestions();
|
|
1499
|
+
addBoat();
|
|
1500
|
+
}
|
|
1501
|
+
} else if (e.key === 'Escape') {
|
|
1502
|
+
hideSuggestions();
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
courseToggleBtn.addEventListener('click', () => {
|
|
1506
|
+
courseBody.hidden = !courseBody.hidden;
|
|
1507
|
+
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
1508
|
+
if (!courseBody.hidden) renderChart();
|
|
1509
|
+
});
|
|
1510
|
+
addMarkBtn.addEventListener('click', () => {
|
|
1511
|
+
markRefs.push(buildMarkRow(null));
|
|
1512
|
+
renderMarkRows();
|
|
1513
|
+
});
|
|
1514
|
+
saveCourseBtn.addEventListener('click', saveCourse);
|
|
1515
|
+
replaySlider.addEventListener('input', () => {
|
|
1516
|
+
replayLive = false;
|
|
1517
|
+
replayTime = Number(replaySlider.value);
|
|
1518
|
+
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
1519
|
+
renderChart();
|
|
1520
|
+
});
|
|
1521
|
+
replayLiveBtn.addEventListener('click', () => {
|
|
1522
|
+
replayLive = true;
|
|
1523
|
+
renderChart();
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
async function init() {
|
|
1527
|
+
await loadVetEnabled();
|
|
1528
|
+
await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
|
|
1529
|
+
await loadRaceState();
|
|
1530
|
+
render();
|
|
1531
|
+
setInterval(render, 1000);
|
|
1532
|
+
setInterval(loadVessels, 10000);
|
|
1533
|
+
setInterval(() => {
|
|
1534
|
+
if (activeRaceId) loadRaceState();
|
|
1535
|
+
}, 5000);
|
|
1536
|
+
setInterval(loadRacesList, 15000);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
init();
|
|
1540
|
+
})();
|