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/index.js
ADDED
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const ExcelJS = require('exceljs');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
|
|
6
|
+
const HANDICAP_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function parseCsvText(text) {
|
|
9
|
+
const rows = [];
|
|
10
|
+
let row = [];
|
|
11
|
+
let field = '';
|
|
12
|
+
let inQuotes = false;
|
|
13
|
+
for (let i = 0; i < text.length; i++) {
|
|
14
|
+
const c = text[i];
|
|
15
|
+
if (inQuotes) {
|
|
16
|
+
if (c === '"') {
|
|
17
|
+
if (text[i + 1] === '"') {
|
|
18
|
+
field += '"';
|
|
19
|
+
i++;
|
|
20
|
+
} else {
|
|
21
|
+
inQuotes = false;
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
field += c;
|
|
25
|
+
}
|
|
26
|
+
} else if (c === '"') {
|
|
27
|
+
inQuotes = true;
|
|
28
|
+
} else if (c === ',') {
|
|
29
|
+
row.push(field);
|
|
30
|
+
field = '';
|
|
31
|
+
} else if (c === '\n') {
|
|
32
|
+
row.push(field);
|
|
33
|
+
rows.push(row);
|
|
34
|
+
row = [];
|
|
35
|
+
field = '';
|
|
36
|
+
} else if (c === '\r') {
|
|
37
|
+
// ignore, \n (possibly preceded by \r) ends the row
|
|
38
|
+
} else {
|
|
39
|
+
field += c;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (field.length || row.length) {
|
|
43
|
+
row.push(field);
|
|
44
|
+
rows.push(row);
|
|
45
|
+
}
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The register uses Norwegian decimal commas (e.g. "1,07"), quoted in the
|
|
50
|
+
// CSV export precisely because the comma would otherwise look like a field
|
|
51
|
+
// separator.
|
|
52
|
+
function parseNorwegianNumber(s) {
|
|
53
|
+
if (s == null) return null;
|
|
54
|
+
const t = String(s).trim().replace(',', '.');
|
|
55
|
+
if (t === '') return null;
|
|
56
|
+
const n = Number(t);
|
|
57
|
+
return isFinite(n) ? n : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The sheet has a title-row block, a sail-area-weighting block, and then the
|
|
61
|
+
// real header row (identifiable by having both "Klasse" and "VET 1" cells).
|
|
62
|
+
// Boat rows follow it: name, validity flag, then a repeating
|
|
63
|
+
// [sail-config label, VET value] pair per rating (up to 3).
|
|
64
|
+
function parseHandicapSheet(csvText) {
|
|
65
|
+
const rows = parseCsvText(csvText);
|
|
66
|
+
const headerRowIndex = rows.findIndex((r) => r.includes('Klasse') && r.includes('VET 1'));
|
|
67
|
+
if (headerRowIndex === -1) {
|
|
68
|
+
throw new Error('Could not find the "Klasse" / "VET 1" header row in the handicap sheet');
|
|
69
|
+
}
|
|
70
|
+
const header = rows[headerRowIndex];
|
|
71
|
+
const col = (name) => header.indexOf(name);
|
|
72
|
+
const vetCols = [col('VET 1'), col('VET 2'), col('VET 3')].filter((i) => i !== -1);
|
|
73
|
+
const classCol = col('Klasse');
|
|
74
|
+
const ownerCol = col('Eier');
|
|
75
|
+
|
|
76
|
+
const boats = [];
|
|
77
|
+
for (let r = headerRowIndex + 1; r < rows.length; r++) {
|
|
78
|
+
const row = rows[r];
|
|
79
|
+
const name = (row[0] || '').trim();
|
|
80
|
+
if (!name) continue;
|
|
81
|
+
const validity = (row[1] || '').trim();
|
|
82
|
+
const vets = vetCols
|
|
83
|
+
.map((vc, idx) => {
|
|
84
|
+
const value = parseNorwegianNumber(row[vc]);
|
|
85
|
+
if (value == null) return null;
|
|
86
|
+
const label = (row[vc - 1] || '').replace(/:\s*$/, '').trim() || `VET ${idx + 1}`;
|
|
87
|
+
return { label, value };
|
|
88
|
+
})
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
if (!vets.length) continue;
|
|
91
|
+
boats.push({
|
|
92
|
+
name,
|
|
93
|
+
validity,
|
|
94
|
+
class: classCol !== -1 ? (row[classCol] || '').trim() : '',
|
|
95
|
+
owner: ownerCol !== -1 ? (row[ownerCol] || '').trim() : '',
|
|
96
|
+
vets
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return boats;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractGoogleSheetId(text) {
|
|
103
|
+
const m = text.match(/docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
104
|
+
return m ? m[1] : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The sheet ID changes year to year, so by default we re-resolve it from the
|
|
108
|
+
// SSCA page that links to "this year's" register rather than hardcoding an
|
|
109
|
+
// ID that will go stale. A directly-configured Google Sheets URL skips the
|
|
110
|
+
// page-scrape and is used as-is.
|
|
111
|
+
async function resolveHandicapCsvUrl(sourceUrl) {
|
|
112
|
+
const directId = extractGoogleSheetId(sourceUrl);
|
|
113
|
+
if (directId) {
|
|
114
|
+
return `https://docs.google.com/spreadsheets/d/${directId}/export?format=csv`;
|
|
115
|
+
}
|
|
116
|
+
const res = await fetch(sourceUrl);
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
throw new Error(`Could not load ${sourceUrl}: HTTP ${res.status}`);
|
|
119
|
+
}
|
|
120
|
+
const html = await res.text();
|
|
121
|
+
const sheetId = extractGoogleSheetId(html);
|
|
122
|
+
if (!sheetId) {
|
|
123
|
+
throw new Error(`No Google Sheets link found on ${sourceUrl}`);
|
|
124
|
+
}
|
|
125
|
+
return `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function makeRaceId() {
|
|
129
|
+
return 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function makeBoatId() {
|
|
133
|
+
return 'b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function raceSummary(race) {
|
|
137
|
+
const boats = Object.values(race.boats);
|
|
138
|
+
return {
|
|
139
|
+
id: race.id,
|
|
140
|
+
name: race.name,
|
|
141
|
+
createdAt: race.createdAt,
|
|
142
|
+
scheduledStart: race.scheduledStart,
|
|
143
|
+
startTime: race.startTime,
|
|
144
|
+
boatCount: boats.length,
|
|
145
|
+
finishedCount: boats.filter((b) => b.finishTime).length,
|
|
146
|
+
dnfCount: boats.filter((b) => b.dnf).length
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function emptyCourse() {
|
|
151
|
+
return { startLine: null, marks: [], finishLine: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Pre-existing races (saved before these fields existed) are missing them —
|
|
155
|
+
// backfill in place so every handler can assume they exist.
|
|
156
|
+
function ensureRaceShape(race) {
|
|
157
|
+
if (!race.course) race.course = emptyCourse();
|
|
158
|
+
if (race.selfBoatId === undefined) race.selfBoatId = null;
|
|
159
|
+
if (race.stopTime === undefined) race.stopTime = null;
|
|
160
|
+
if (race.scheduledCallOff === undefined) race.scheduledCallOff = null;
|
|
161
|
+
Object.values(race.boats).forEach((b) => {
|
|
162
|
+
if (!b.track) b.track = [];
|
|
163
|
+
if (b.dnf === undefined) b.dnf = false;
|
|
164
|
+
if (b.dnfPosition === undefined) b.dnfPosition = null;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const EARTH_RADIUS_NM = 3440.065;
|
|
169
|
+
const MS_TO_KNOTS = 1.9438444924574;
|
|
170
|
+
|
|
171
|
+
function toRad(deg) {
|
|
172
|
+
return (deg * Math.PI) / 180;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function distanceNm(a, b) {
|
|
176
|
+
const dLat = toRad(b.lat - a.lat);
|
|
177
|
+
const dLon = toRad(b.lon - a.lon);
|
|
178
|
+
const lat1 = toRad(a.lat);
|
|
179
|
+
const lat2 = toRad(b.lat);
|
|
180
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
|
181
|
+
return 2 * EARTH_RADIUS_NM * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function midpoint(a, b) {
|
|
185
|
+
return { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateCoordPoint(p) {
|
|
189
|
+
if (!p || typeof p.lat !== 'number' || typeof p.lon !== 'number' || !isFinite(p.lat) || !isFinite(p.lon)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (p.lat < -90 || p.lat > 90 || p.lon < -180 || p.lon > 180) return null;
|
|
193
|
+
const out = { lat: p.lat, lon: p.lon };
|
|
194
|
+
const name = (p.name || '').toString().trim();
|
|
195
|
+
if (name) out.name = name;
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateLine(line) {
|
|
200
|
+
if (line === null) return null;
|
|
201
|
+
if (!Array.isArray(line) || line.length !== 2) return undefined; // undefined = invalid, caller rejects
|
|
202
|
+
const a = validateCoordPoint(line[0]);
|
|
203
|
+
const b = validateCoordPoint(line[1]);
|
|
204
|
+
if (!a || !b) return undefined;
|
|
205
|
+
return [a, b];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// HH:MM:SS for a duration, or '' when there's nothing to show — matches the
|
|
209
|
+
// webapp's fmtDuration, used for the Excel export's Elapsed/Corrected columns.
|
|
210
|
+
function formatDurationHms(ms) {
|
|
211
|
+
if (ms == null || ms < 0 || !isFinite(ms)) return '';
|
|
212
|
+
const totalSec = Math.floor(ms / 1000);
|
|
213
|
+
const h = Math.floor(totalSec / 3600);
|
|
214
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
215
|
+
const s = totalSec % 60;
|
|
216
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
217
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Renders an absolute instant as the wall-clock time it was in the
|
|
221
|
+
// exporting browser's own timezone (tzOffsetMinutes = that browser's
|
|
222
|
+
// Date.prototype.getTimezoneOffset()) rather than the server's timezone,
|
|
223
|
+
// which may well be different — the server can be headless/UTC while the
|
|
224
|
+
// person opening the spreadsheet is reading it in their own local time.
|
|
225
|
+
function formatLocalTime(utcMs, tzOffsetMinutes) {
|
|
226
|
+
if (utcMs == null) return '';
|
|
227
|
+
const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
|
|
228
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
229
|
+
return `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatLocalDateTime(utcMs, tzOffsetMinutes) {
|
|
233
|
+
if (utcMs == null) return '';
|
|
234
|
+
const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
|
|
235
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
236
|
+
return (
|
|
237
|
+
`${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} ` +
|
|
238
|
+
`${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = function (app) {
|
|
243
|
+
const plugin = {};
|
|
244
|
+
|
|
245
|
+
plugin.id = 'race-control';
|
|
246
|
+
plugin.name = 'Race Control';
|
|
247
|
+
plugin.description =
|
|
248
|
+
'Elapsed and handicap-corrected (Time-on-Time) race time tracking for named, plannable races, with a webapp UI, per-boat editable TCF, and VET-tall import from SSCA.';
|
|
249
|
+
|
|
250
|
+
plugin.schema = {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
defaultTcf: {
|
|
254
|
+
type: 'number',
|
|
255
|
+
title: 'Default Time-on-Time correction factor (TCF) applied to newly seen boats',
|
|
256
|
+
default: 1.0
|
|
257
|
+
},
|
|
258
|
+
handicapSourceUrl: {
|
|
259
|
+
type: 'string',
|
|
260
|
+
title:
|
|
261
|
+
'VET-tall page to resolve the current handicap register from (or a direct Google Sheets link to override)',
|
|
262
|
+
default: DEFAULT_HANDICAP_SOURCE_PAGE
|
|
263
|
+
},
|
|
264
|
+
vetEnabled: {
|
|
265
|
+
type: 'boolean',
|
|
266
|
+
title:
|
|
267
|
+
'Use the VET-tall register for autocomplete and per-boat handicap alternatives. When off, every boat is treated as outside VET: TCF is remembered per boat name across races instead.',
|
|
268
|
+
default: false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Races are kept by id so several can be planned ahead and reviewed after
|
|
274
|
+
// the fact, rather than a single race getting overwritten by Reset.
|
|
275
|
+
// boatRegistry is a separate, cross-race name->MMSI/TCF memory: once a
|
|
276
|
+
// boat's MMSI or (for boats outside VET) TCF is entered anywhere, it's
|
|
277
|
+
// applied automatically next time that name is used in any race.
|
|
278
|
+
let state = { races: {}, order: [], currentRaceId: null, boatRegistry: {} };
|
|
279
|
+
let dataFile = null;
|
|
280
|
+
const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
|
|
281
|
+
const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
|
|
282
|
+
let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
|
|
283
|
+
|
|
284
|
+
function loadState() {
|
|
285
|
+
try {
|
|
286
|
+
const raw = fs.readFileSync(dataFile, 'utf8');
|
|
287
|
+
const loaded = JSON.parse(raw);
|
|
288
|
+
state = {
|
|
289
|
+
races: loaded.races || {},
|
|
290
|
+
order: loaded.order || [],
|
|
291
|
+
currentRaceId: loaded.currentRaceId || null,
|
|
292
|
+
boatRegistry: loaded.boatRegistry || {}
|
|
293
|
+
};
|
|
294
|
+
} catch (e) {
|
|
295
|
+
state = { races: {}, order: [], currentRaceId: null, boatRegistry: {} };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Cross-race memory keyed by boat name, merging in whichever fields are
|
|
300
|
+
// given (mmsi and/or tcf) without clobbering the other. tcf is only ever
|
|
301
|
+
// written here for boats outside the VET register — see the callers.
|
|
302
|
+
function upsertBoatRegistry(name, fields) {
|
|
303
|
+
if (!name) return;
|
|
304
|
+
const key = name.trim().toLowerCase();
|
|
305
|
+
const existing = state.boatRegistry[key] || { name: name.trim() };
|
|
306
|
+
existing.name = name.trim();
|
|
307
|
+
if (fields.mmsi) existing.mmsi = String(fields.mmsi).trim();
|
|
308
|
+
if (fields.tcf != null) existing.tcf = fields.tcf;
|
|
309
|
+
state.boatRegistry[key] = existing;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function getRegistryEntry(name) {
|
|
313
|
+
return state.boatRegistry[(name || '').trim().toLowerCase()] || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function lookupBoatRegistry(name) {
|
|
317
|
+
const entry = getRegistryEntry(name);
|
|
318
|
+
return entry ? entry.mmsi : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// vetEnabled is a plugin config setting (Server -> Plugin Config), not
|
|
322
|
+
// per-race and not editable from the webapp itself. Off by default — a
|
|
323
|
+
// fresh install (or one that's never touched this setting) treats every
|
|
324
|
+
// boat as outside VET until it's explicitly turned on.
|
|
325
|
+
function isVetEnabled() {
|
|
326
|
+
return !!(plugin.options && plugin.options.vetEnabled === true);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A boat only counts as "in VET" for registry-TCF purposes while the
|
|
330
|
+
// config setting has VET enabled and the register (whatever's currently
|
|
331
|
+
// cached) actually has a matching name — disabling VET makes every boat
|
|
332
|
+
// "outside VET" for this purpose too, per the setting's whole point.
|
|
333
|
+
function isVetMatch(name) {
|
|
334
|
+
if (!isVetEnabled()) return false;
|
|
335
|
+
const n = (name || '').trim().toLowerCase();
|
|
336
|
+
return handicapCache.boats.some((b) => b.name.toLowerCase() === n);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function saveState() {
|
|
340
|
+
try {
|
|
341
|
+
fs.writeFileSync(dataFile, JSON.stringify(state, null, 2));
|
|
342
|
+
} catch (e) {
|
|
343
|
+
app.error('race-control: failed to persist state: ' + e.message);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getRace(id) {
|
|
348
|
+
return state.races[id] || null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getBoat(race, boatId) {
|
|
352
|
+
return race.boats[boatId] || null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function contextForMmsi(mmsi) {
|
|
356
|
+
return `vessels.urn:mrn:imo:mmsi:${mmsi}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Live snapshot straight from SignalK's in-memory model (no HTTP
|
|
360
|
+
// round-trip) — used both for track recording and the finish estimate.
|
|
361
|
+
function getLivePosition(mmsi) {
|
|
362
|
+
if (!mmsi) return null;
|
|
363
|
+
try {
|
|
364
|
+
const posLeaf = app.getPath(`${contextForMmsi(mmsi)}.navigation.position`);
|
|
365
|
+
const pos = posLeaf && posLeaf.value;
|
|
366
|
+
if (!pos || typeof pos.latitude !== 'number' || typeof pos.longitude !== 'number') return null;
|
|
367
|
+
const sogLeaf = app.getPath(`${contextForMmsi(mmsi)}.navigation.speedOverGround`);
|
|
368
|
+
const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
|
|
369
|
+
return { lat: pos.latitude, lon: pos.longitude, sogMs };
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Live position if we have one right now, else the last recorded track
|
|
376
|
+
// point, else nothing — used when marking a boat DNF, since by then it
|
|
377
|
+
// may no longer be broadcasting AIS.
|
|
378
|
+
function getLastKnownPosition(boat) {
|
|
379
|
+
const live = getLivePosition(boat.mmsi);
|
|
380
|
+
if (live) return { lat: live.lat, lon: live.lon };
|
|
381
|
+
if (boat.track && boat.track.length) {
|
|
382
|
+
const last = boat.track[boat.track.length - 1];
|
|
383
|
+
return { lat: last.lat, lon: last.lon };
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A mark counts as "rounded" once the boat's recorded track came within
|
|
389
|
+
// this radius of it. ~0.1nm (~185m) — loose enough to tolerate AIS
|
|
390
|
+
// position jitter and the 15s sampling gap (a boat doing 7kn covers about
|
|
391
|
+
// 0.03nm between samples) without needing an exact pass.
|
|
392
|
+
const MARK_ROUNDING_RADIUS_NM = 0.1;
|
|
393
|
+
|
|
394
|
+
// Scans a boat's recorded track chronologically, advancing to the next
|
|
395
|
+
// mark each time the track comes within rounding radius of the current
|
|
396
|
+
// one — so it naturally requires marks to be rounded in course order.
|
|
397
|
+
// Automatic (no manual "boat X rounded mark Y" input) by design, at the
|
|
398
|
+
// cost of missing a rounding if a boat cuts far outside the radius.
|
|
399
|
+
function countRoundedMarks(race, boat) {
|
|
400
|
+
const marks = race.course.marks;
|
|
401
|
+
if (!marks.length || !boat.track || !boat.track.length) return 0;
|
|
402
|
+
let markIdx = 0;
|
|
403
|
+
for (const pt of boat.track) {
|
|
404
|
+
if (markIdx >= marks.length) break;
|
|
405
|
+
if (distanceNm(pt, marks[markIdx]) <= MARK_ROUNDING_RADIUS_NM) {
|
|
406
|
+
markIdx++;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return markIdx;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Distance from the boat's current position, around each remaining mark
|
|
413
|
+
// in order, to the finish line — not just a straight line to the finish.
|
|
414
|
+
// Returns null if there's nothing left to route to (no remaining marks and
|
|
415
|
+
// no finish line).
|
|
416
|
+
function remainingCourseDistanceNm(race, boat, currentPos) {
|
|
417
|
+
const roundedCount = countRoundedMarks(race, boat);
|
|
418
|
+
const waypoints = race.course.marks.slice(roundedCount).map((m) => ({ lat: m.lat, lon: m.lon }));
|
|
419
|
+
if (race.course.finishLine) waypoints.push(midpoint(race.course.finishLine[0], race.course.finishLine[1]));
|
|
420
|
+
if (!waypoints.length) return null;
|
|
421
|
+
let total = 0;
|
|
422
|
+
let from = currentPos;
|
|
423
|
+
for (const wp of waypoints) {
|
|
424
|
+
total += distanceNm(from, wp);
|
|
425
|
+
from = wp;
|
|
426
|
+
}
|
|
427
|
+
return { nm: total, marksRemaining: race.course.marks.length - roundedCount };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Distance-to-go (current position, around remaining marks in rounding
|
|
431
|
+
// order, to the finish line) divided by current speed over ground.
|
|
432
|
+
// Accurate only as far as the automatic mark-rounding detection above is;
|
|
433
|
+
// if a boat hasn't been recorded rounding a mark yet, that mark (and
|
|
434
|
+
// everything after it) is still counted as ahead of it. Only offered when
|
|
435
|
+
// there's a finish line, a live position, and a non-trivial speed.
|
|
436
|
+
function estimateFinish(race, boat) {
|
|
437
|
+
if (!race.startTime || boat.finishTime || boat.dnf || race.stopTime) return null;
|
|
438
|
+
if (!race.course || !race.course.finishLine) return null;
|
|
439
|
+
const live = getLivePosition(boat.mmsi);
|
|
440
|
+
if (!live || live.sogMs == null || live.sogMs < 0.25) return null;
|
|
441
|
+
const remaining = remainingCourseDistanceNm(race, boat, live);
|
|
442
|
+
if (remaining == null) return null;
|
|
443
|
+
const sogKn = live.sogMs * MS_TO_KNOTS;
|
|
444
|
+
const hoursRemaining = remaining.nm / sogKn;
|
|
445
|
+
const estFinishTime = Date.now() + hoursRemaining * 3600 * 1000;
|
|
446
|
+
const estElapsedMs = estFinishTime - race.startTime;
|
|
447
|
+
const tcf = boat.tcf != null ? boat.tcf : 1.0;
|
|
448
|
+
return {
|
|
449
|
+
remainingNm: Math.round(remaining.nm * 100) / 100,
|
|
450
|
+
marksRemaining: remaining.marksRemaining,
|
|
451
|
+
sogKn: Math.round(sogKn * 10) / 10,
|
|
452
|
+
estFinishTime,
|
|
453
|
+
estElapsedMs,
|
|
454
|
+
estCorrectedMs: estElapsedMs * tcf
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Attaches a live `estimate` to each unfinished boat without mutating the
|
|
459
|
+
// stored race — it's derived from live data, never persisted.
|
|
460
|
+
function raceWithEstimates(race) {
|
|
461
|
+
const out = JSON.parse(JSON.stringify(race));
|
|
462
|
+
Object.values(out.boats).forEach((b) => {
|
|
463
|
+
b.estimate = estimateFinish(race, race.boats[b.id]);
|
|
464
|
+
});
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Same ranking the webapp's table uses (finished boats by real corrected
|
|
469
|
+
// time, still-racing boats by projected corrected time when available,
|
|
470
|
+
// DNF/unrankable boats last) — shared by the Excel export so its row
|
|
471
|
+
// order and the on-screen order always agree.
|
|
472
|
+
function rankedBoatList(race) {
|
|
473
|
+
const now = race.stopTime || Date.now();
|
|
474
|
+
return Object.values(race.boats)
|
|
475
|
+
.map((boat) => {
|
|
476
|
+
const elapsedMs = !boat.dnf && race.startTime ? (boat.finishTime || now) - race.startTime : null;
|
|
477
|
+
const tcf = boat.tcf != null ? boat.tcf : 1.0;
|
|
478
|
+
const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
|
|
479
|
+
const estimate = estimateFinish(race, boat);
|
|
480
|
+
const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
|
|
481
|
+
return { boat, elapsedMs, correctedMs, estimate, rankMs };
|
|
482
|
+
})
|
|
483
|
+
.sort((a, b) => {
|
|
484
|
+
if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
|
|
485
|
+
if (a.rankMs == null) return 1;
|
|
486
|
+
if (b.rankMs == null) return -1;
|
|
487
|
+
return a.rankMs - b.rankMs;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function waypointResource(name, pt) {
|
|
492
|
+
return {
|
|
493
|
+
name,
|
|
494
|
+
feature: {
|
|
495
|
+
type: 'Feature',
|
|
496
|
+
geometry: { type: 'Point', coordinates: [pt.lon, pt.lat] },
|
|
497
|
+
properties: {}
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function routeResource(name, points) {
|
|
503
|
+
return {
|
|
504
|
+
name,
|
|
505
|
+
feature: {
|
|
506
|
+
type: 'Feature',
|
|
507
|
+
geometry: { type: 'LineString', coordinates: points.map((p) => [p.lon, p.lat]) },
|
|
508
|
+
properties: {}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Best-effort: publishes the course as SignalK waypoint/route resources so
|
|
514
|
+
// any chart plotter (freeboard-sk etc.) that reads the standard resources
|
|
515
|
+
// API shows it overlaid on a real chart. Silently no-ops if this server
|
|
516
|
+
// has no resources provider registered — core race-control features never
|
|
517
|
+
// depend on this succeeding.
|
|
518
|
+
async function publishCourseResources(race) {
|
|
519
|
+
if (!app.resourcesApi || typeof app.resourcesApi.setResource !== 'function') return;
|
|
520
|
+
try {
|
|
521
|
+
const points = [];
|
|
522
|
+
const put = (id, name, pt) => app.resourcesApi.setResource('waypoints', id, waypointResource(name, pt));
|
|
523
|
+
if (race.course.startLine) {
|
|
524
|
+
await put(`race-${race.id}-start-pin`, `${race.name} — Start (pin)`, race.course.startLine[0]);
|
|
525
|
+
await put(`race-${race.id}-start-committee`, `${race.name} — Start (committee)`, race.course.startLine[1]);
|
|
526
|
+
points.push(race.course.startLine[0], race.course.startLine[1]);
|
|
527
|
+
}
|
|
528
|
+
for (const m of race.course.marks) {
|
|
529
|
+
await put(`race-${race.id}-mark-${m.id}`, `${race.name} — ${m.name}`, m);
|
|
530
|
+
points.push(m);
|
|
531
|
+
}
|
|
532
|
+
if (race.course.finishLine) {
|
|
533
|
+
await put(`race-${race.id}-finish-pin`, `${race.name} — Finish (pin)`, race.course.finishLine[0]);
|
|
534
|
+
await put(`race-${race.id}-finish-committee`, `${race.name} — Finish (committee)`, race.course.finishLine[1]);
|
|
535
|
+
points.push(race.course.finishLine[0], race.course.finishLine[1]);
|
|
536
|
+
}
|
|
537
|
+
if (points.length >= 2) {
|
|
538
|
+
await app.resourcesApi.setResource('routes', `race-${race.id}-course`, routeResource(race.name, points));
|
|
539
|
+
}
|
|
540
|
+
} catch (e) {
|
|
541
|
+
app.debug('race-control: could not publish course to SignalK resources: ' + e.message);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Every live (started, not all finished) race's unfinished boats get a
|
|
546
|
+
// position sample appended periodically — the recorded trail is what the
|
|
547
|
+
// webapp's course/replay chart draws.
|
|
548
|
+
function recordTrackSample() {
|
|
549
|
+
let changed = false;
|
|
550
|
+
Object.values(state.races).forEach((race) => {
|
|
551
|
+
if (!race.startTime || race.stopTime) return;
|
|
552
|
+
Object.values(race.boats).forEach((boat) => {
|
|
553
|
+
if (boat.finishTime || boat.dnf || !boat.mmsi) return;
|
|
554
|
+
const live = getLivePosition(boat.mmsi);
|
|
555
|
+
if (!live) return;
|
|
556
|
+
if (!boat.track) boat.track = [];
|
|
557
|
+
boat.track.push({ t: Date.now(), lat: live.lat, lon: live.lon });
|
|
558
|
+
if (boat.track.length > 2000) boat.track.shift();
|
|
559
|
+
changed = true;
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
if (changed) saveState();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function disarmSchedule(raceId) {
|
|
566
|
+
const t = scheduleTimers.get(raceId);
|
|
567
|
+
if (t) {
|
|
568
|
+
clearTimeout(t);
|
|
569
|
+
scheduleTimers.delete(raceId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function doStart(race, atTime) {
|
|
574
|
+
disarmSchedule(race.id);
|
|
575
|
+
disarmCallOffSchedule(race.id);
|
|
576
|
+
race.startTime = atTime;
|
|
577
|
+
race.scheduledStart = null;
|
|
578
|
+
race.stopTime = null;
|
|
579
|
+
race.scheduledCallOff = null;
|
|
580
|
+
Object.values(race.boats).forEach((b) => {
|
|
581
|
+
b.finishTime = null;
|
|
582
|
+
b.track = [];
|
|
583
|
+
b.dnf = false;
|
|
584
|
+
b.dnfPosition = null;
|
|
585
|
+
});
|
|
586
|
+
saveState();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Re-arms (or clears) the timer that auto-starts a race at its
|
|
590
|
+
// scheduledStart. Called on every state change that touches
|
|
591
|
+
// scheduledStart, and once per race at plugin startup so a schedule set
|
|
592
|
+
// before a server restart still fires.
|
|
593
|
+
function armSchedule(race) {
|
|
594
|
+
disarmSchedule(race.id);
|
|
595
|
+
if (race.scheduledStart && !race.startTime) {
|
|
596
|
+
const delay = race.scheduledStart - Date.now();
|
|
597
|
+
if (delay <= 0) {
|
|
598
|
+
doStart(race, race.scheduledStart);
|
|
599
|
+
} else {
|
|
600
|
+
scheduleTimers.set(
|
|
601
|
+
race.id,
|
|
602
|
+
setTimeout(() => doStart(race, race.scheduledStart), delay)
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function disarmCallOffSchedule(raceId) {
|
|
609
|
+
const t = callOffTimers.get(raceId);
|
|
610
|
+
if (t) {
|
|
611
|
+
clearTimeout(t);
|
|
612
|
+
callOffTimers.delete(raceId);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Calling off a race (whether by clicking Stop or via a scheduled
|
|
617
|
+
// call-off firing) freezes the clock and marks every boat that hasn't
|
|
618
|
+
// finished as DNF, capturing its last known position if one's available —
|
|
619
|
+
// finished boats are left alone.
|
|
620
|
+
function doStop(race, atTime) {
|
|
621
|
+
disarmCallOffSchedule(race.id);
|
|
622
|
+
race.stopTime = atTime;
|
|
623
|
+
race.scheduledCallOff = null;
|
|
624
|
+
Object.values(race.boats).forEach((boat) => {
|
|
625
|
+
if (!boat.finishTime && !boat.dnf) {
|
|
626
|
+
boat.dnf = true;
|
|
627
|
+
boat.dnfPosition = getLastKnownPosition(boat);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
saveState();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Re-arms (or clears) the timer that auto-calls-off a race at its
|
|
634
|
+
// scheduledCallOff, mirroring armSchedule for the start.
|
|
635
|
+
function armCallOffSchedule(race) {
|
|
636
|
+
disarmCallOffSchedule(race.id);
|
|
637
|
+
if (race.scheduledCallOff && race.startTime && !race.stopTime) {
|
|
638
|
+
const delay = race.scheduledCallOff - Date.now();
|
|
639
|
+
if (delay <= 0) {
|
|
640
|
+
doStop(race, race.scheduledCallOff);
|
|
641
|
+
} else {
|
|
642
|
+
callOffTimers.set(
|
|
643
|
+
race.id,
|
|
644
|
+
setTimeout(() => doStop(race, race.scheduledCallOff), delay)
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function fetchHandicapBoats(forceRefresh) {
|
|
651
|
+
const sourceUrl = (plugin.options && plugin.options.handicapSourceUrl) || DEFAULT_HANDICAP_SOURCE_PAGE;
|
|
652
|
+
const age = Date.now() - handicapCache.fetchedAt;
|
|
653
|
+
if (!forceRefresh && handicapCache.sourceUrl === sourceUrl && age < HANDICAP_CACHE_TTL_MS) {
|
|
654
|
+
return handicapCache;
|
|
655
|
+
}
|
|
656
|
+
const csvUrl = await resolveHandicapCsvUrl(sourceUrl);
|
|
657
|
+
const res = await fetch(csvUrl);
|
|
658
|
+
if (!res.ok) {
|
|
659
|
+
throw new Error(`Handicap sheet returned HTTP ${res.status}`);
|
|
660
|
+
}
|
|
661
|
+
const csvText = await res.text();
|
|
662
|
+
const boats = parseHandicapSheet(csvText);
|
|
663
|
+
handicapCache = { fetchedAt: Date.now(), boats, sourceUrl, csvUrl };
|
|
664
|
+
return handicapCache;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let trackTimer = null;
|
|
668
|
+
|
|
669
|
+
plugin.start = function (options) {
|
|
670
|
+
plugin.options = options || {};
|
|
671
|
+
dataFile = path.join(app.getDataDirPath(), 'race-state.json');
|
|
672
|
+
loadState();
|
|
673
|
+
Object.values(state.races).forEach((race) => {
|
|
674
|
+
ensureRaceShape(race);
|
|
675
|
+
armSchedule(race);
|
|
676
|
+
armCallOffSchedule(race);
|
|
677
|
+
});
|
|
678
|
+
trackTimer = setInterval(recordTrackSample, 15000);
|
|
679
|
+
app.setPluginStatus('Race control ready');
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
plugin.stop = function () {
|
|
683
|
+
scheduleTimers.forEach((t) => clearTimeout(t));
|
|
684
|
+
scheduleTimers.clear();
|
|
685
|
+
callOffTimers.forEach((t) => clearTimeout(t));
|
|
686
|
+
callOffTimers.clear();
|
|
687
|
+
if (trackTimer) {
|
|
688
|
+
clearInterval(trackTimer);
|
|
689
|
+
trackTimer = null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
plugin.registerWithRouter = function (router) {
|
|
694
|
+
router.get('/races', (req, res) => {
|
|
695
|
+
const races = state.order.map((id) => raceSummary(state.races[id])).filter(Boolean);
|
|
696
|
+
res.json({ currentRaceId: state.currentRaceId, races });
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
router.post('/races', (req, res) => {
|
|
700
|
+
const name = ((req.body && req.body.name) || '').trim();
|
|
701
|
+
if (!name) {
|
|
702
|
+
return res.status(400).json({ error: 'name is required' });
|
|
703
|
+
}
|
|
704
|
+
const scheduledStart = req.body && req.body.scheduledStart != null ? Number(req.body.scheduledStart) : null;
|
|
705
|
+
const race = {
|
|
706
|
+
id: makeRaceId(),
|
|
707
|
+
name,
|
|
708
|
+
createdAt: Date.now(),
|
|
709
|
+
scheduledStart: isFinite(scheduledStart) ? scheduledStart : null,
|
|
710
|
+
startTime: null,
|
|
711
|
+
stopTime: null,
|
|
712
|
+
scheduledCallOff: null,
|
|
713
|
+
boats: {},
|
|
714
|
+
course: emptyCourse(),
|
|
715
|
+
selfBoatId: null
|
|
716
|
+
};
|
|
717
|
+
state.races[race.id] = race;
|
|
718
|
+
state.order.push(race.id);
|
|
719
|
+
state.currentRaceId = race.id;
|
|
720
|
+
saveState();
|
|
721
|
+
armSchedule(race);
|
|
722
|
+
res.json({ currentRaceId: state.currentRaceId, race });
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
router.get('/races/:id', (req, res) => {
|
|
726
|
+
const race = getRace(req.params.id);
|
|
727
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
728
|
+
ensureRaceShape(race);
|
|
729
|
+
res.json(raceWithEstimates(race));
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// A snapshot of the current standings as a downloadable .xlsx — same
|
|
733
|
+
// rows, same order as the webapp's table. ?tzOffsetMinutes=<n> should be
|
|
734
|
+
// the exporting browser's own Date.prototype.getTimezoneOffset(), so
|
|
735
|
+
// the Finish Time column reads in their local time, not the server's.
|
|
736
|
+
router.get('/races/:id/export.xlsx', async (req, res) => {
|
|
737
|
+
const race = getRace(req.params.id);
|
|
738
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
739
|
+
ensureRaceShape(race);
|
|
740
|
+
const tzParsed = Number(req.query.tzOffsetMinutes);
|
|
741
|
+
const tz = isFinite(tzParsed) ? tzParsed : new Date().getTimezoneOffset();
|
|
742
|
+
|
|
743
|
+
const ranked = rankedBoatList(race);
|
|
744
|
+
const headers = ['Rank', 'Boat', 'MMSI', 'TCF', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
|
|
745
|
+
|
|
746
|
+
const workbook = new ExcelJS.Workbook();
|
|
747
|
+
workbook.creator = 'Race Control';
|
|
748
|
+
workbook.created = new Date();
|
|
749
|
+
const sheet = workbook.addWorksheet('Results');
|
|
750
|
+
|
|
751
|
+
const titleRow = sheet.addRow([race.name]);
|
|
752
|
+
sheet.mergeCells(titleRow.number, 1, titleRow.number, headers.length);
|
|
753
|
+
titleRow.getCell(1).font = { bold: true, size: 14 };
|
|
754
|
+
|
|
755
|
+
const statusLine = race.stopTime
|
|
756
|
+
? `Called off ${formatLocalDateTime(race.stopTime, tz)}`
|
|
757
|
+
: race.startTime
|
|
758
|
+
? `Started ${formatLocalDateTime(race.startTime, tz)}`
|
|
759
|
+
: 'Not started';
|
|
760
|
+
const infoRow = sheet.addRow([statusLine]);
|
|
761
|
+
sheet.mergeCells(infoRow.number, 1, infoRow.number, headers.length);
|
|
762
|
+
infoRow.getCell(1).font = { italic: true, color: { argb: 'FF64748B' } };
|
|
763
|
+
|
|
764
|
+
sheet.addRow([]);
|
|
765
|
+
|
|
766
|
+
const headerRow = sheet.addRow(headers);
|
|
767
|
+
headerRow.font = { bold: true };
|
|
768
|
+
headerRow.eachCell((cell) => {
|
|
769
|
+
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
ranked.forEach((r, i) => {
|
|
773
|
+
const status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
|
|
774
|
+
const rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
|
|
775
|
+
sheet.addRow([
|
|
776
|
+
rankLabel,
|
|
777
|
+
r.boat.name,
|
|
778
|
+
r.boat.mmsi || '',
|
|
779
|
+
r.boat.tcf,
|
|
780
|
+
formatDurationHms(r.elapsedMs),
|
|
781
|
+
formatDurationHms(r.correctedMs),
|
|
782
|
+
r.boat.finishTime ? formatLocalTime(r.boat.finishTime, tz) : '',
|
|
783
|
+
status
|
|
784
|
+
]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const widths = [7, 24, 12, 8, 12, 12, 12, 12];
|
|
788
|
+
widths.forEach((w, i) => {
|
|
789
|
+
sheet.getColumn(i + 1).width = w;
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
|
|
793
|
+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
794
|
+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}-results.xlsx"`);
|
|
795
|
+
try {
|
|
796
|
+
await workbook.xlsx.write(res);
|
|
797
|
+
res.end();
|
|
798
|
+
} catch (e) {
|
|
799
|
+
app.error('race-control: failed to generate xlsx export: ' + e.message);
|
|
800
|
+
if (!res.headersSent) res.status(500).json({ error: 'Could not generate export' });
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
router.post('/races/:id/select', (req, res) => {
|
|
805
|
+
const race = getRace(req.params.id);
|
|
806
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
807
|
+
state.currentRaceId = race.id;
|
|
808
|
+
saveState();
|
|
809
|
+
res.json({ currentRaceId: state.currentRaceId });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
router.delete('/races/:id', (req, res) => {
|
|
813
|
+
const race = getRace(req.params.id);
|
|
814
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
815
|
+
disarmSchedule(race.id);
|
|
816
|
+
delete state.races[race.id];
|
|
817
|
+
state.order = state.order.filter((id) => id !== race.id);
|
|
818
|
+
if (state.currentRaceId === race.id) {
|
|
819
|
+
state.currentRaceId = state.order.length ? state.order[state.order.length - 1] : null;
|
|
820
|
+
}
|
|
821
|
+
saveState();
|
|
822
|
+
res.json({ currentRaceId: state.currentRaceId });
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
router.post('/races/:id/start', (req, res) => {
|
|
826
|
+
const race = getRace(req.params.id);
|
|
827
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
828
|
+
doStart(race, Date.now());
|
|
829
|
+
res.json(raceWithEstimates(race));
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
router.post('/races/:id/schedule', (req, res) => {
|
|
833
|
+
const race = getRace(req.params.id);
|
|
834
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
835
|
+
if (race.startTime) {
|
|
836
|
+
return res.status(400).json({ error: 'Race has already started' });
|
|
837
|
+
}
|
|
838
|
+
const t = Number(req.body && req.body.startTime);
|
|
839
|
+
if (!isFinite(t)) {
|
|
840
|
+
return res.status(400).json({ error: 'startTime must be an epoch-millisecond timestamp' });
|
|
841
|
+
}
|
|
842
|
+
race.scheduledStart = t;
|
|
843
|
+
saveState();
|
|
844
|
+
armSchedule(race);
|
|
845
|
+
res.json(raceWithEstimates(race));
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
router.post('/races/:id/schedule/cancel', (req, res) => {
|
|
849
|
+
const race = getRace(req.params.id);
|
|
850
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
851
|
+
race.scheduledStart = null;
|
|
852
|
+
saveState();
|
|
853
|
+
armSchedule(race);
|
|
854
|
+
res.json(raceWithEstimates(race));
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
router.post('/races/:id/reset', (req, res) => {
|
|
858
|
+
const race = getRace(req.params.id);
|
|
859
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
860
|
+
disarmSchedule(race.id);
|
|
861
|
+
disarmCallOffSchedule(race.id);
|
|
862
|
+
race.startTime = null;
|
|
863
|
+
race.scheduledStart = null;
|
|
864
|
+
race.stopTime = null;
|
|
865
|
+
race.scheduledCallOff = null;
|
|
866
|
+
Object.values(race.boats).forEach((b) => {
|
|
867
|
+
b.finishTime = null;
|
|
868
|
+
b.track = [];
|
|
869
|
+
b.dnf = false;
|
|
870
|
+
b.dnfPosition = null;
|
|
871
|
+
});
|
|
872
|
+
saveState();
|
|
873
|
+
res.json(raceWithEstimates(race));
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Calls off the race right now: the clock freezes (and track recording
|
|
877
|
+
// / finish estimates stop), and every boat that hasn't finished is
|
|
878
|
+
// marked DNF with its last known position — see doStop. Unlike Reset,
|
|
879
|
+
// this doesn't touch boats, finish times, or the course.
|
|
880
|
+
router.post('/races/:id/stop', (req, res) => {
|
|
881
|
+
const race = getRace(req.params.id);
|
|
882
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
883
|
+
if (!race.startTime) return res.status(400).json({ error: 'Race has not started' });
|
|
884
|
+
if (race.stopTime) return res.status(400).json({ error: 'Race is already stopped' });
|
|
885
|
+
doStop(race, Date.now());
|
|
886
|
+
res.json(raceWithEstimates(race));
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Discards the stop — not a true pause/resume. The clock jumps straight
|
|
890
|
+
// back to real elapsed time (startTime to now); the time spent stopped
|
|
891
|
+
// is not excluded from anyone's elapsed/corrected time. Every DNF'd boat
|
|
892
|
+
// is un-DNF'd too, since calling the race back on means it's still live
|
|
893
|
+
// (there's no separate tracking of "DNF'd by hand" vs "DNF'd by Stop").
|
|
894
|
+
router.post('/races/:id/resume', (req, res) => {
|
|
895
|
+
const race = getRace(req.params.id);
|
|
896
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
897
|
+
if (!race.stopTime) return res.status(400).json({ error: 'Race is not stopped' });
|
|
898
|
+
race.stopTime = null;
|
|
899
|
+
Object.values(race.boats).forEach((boat) => {
|
|
900
|
+
if (boat.dnf) {
|
|
901
|
+
boat.dnf = false;
|
|
902
|
+
boat.dnfPosition = null;
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
saveState();
|
|
906
|
+
res.json(raceWithEstimates(race));
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Schedules an automatic call-off (same effect as Stop) at a future
|
|
910
|
+
// time — mirrors /schedule for the start.
|
|
911
|
+
router.post('/races/:id/schedule-call-off', (req, res) => {
|
|
912
|
+
const race = getRace(req.params.id);
|
|
913
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
914
|
+
if (race.stopTime) return res.status(400).json({ error: 'Race is already stopped' });
|
|
915
|
+
const t = Number(req.body && req.body.time);
|
|
916
|
+
if (!isFinite(t)) {
|
|
917
|
+
return res.status(400).json({ error: 'time must be an epoch-millisecond timestamp' });
|
|
918
|
+
}
|
|
919
|
+
race.scheduledCallOff = t;
|
|
920
|
+
saveState();
|
|
921
|
+
armCallOffSchedule(race);
|
|
922
|
+
res.json(raceWithEstimates(race));
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
router.post('/races/:id/schedule-call-off/cancel', (req, res) => {
|
|
926
|
+
const race = getRace(req.params.id);
|
|
927
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
928
|
+
race.scheduledCallOff = null;
|
|
929
|
+
saveState();
|
|
930
|
+
armCallOffSchedule(race);
|
|
931
|
+
res.json(raceWithEstimates(race));
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Marks one boat as "self" so the webapp can show live/finished
|
|
935
|
+
// comparisons (gap, or a countdown-to-catch-up) against every other
|
|
936
|
+
// boat. Pass boatId: null to clear it.
|
|
937
|
+
router.put('/races/:id/self', (req, res) => {
|
|
938
|
+
const race = getRace(req.params.id);
|
|
939
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
940
|
+
const boatId = (req.body && req.body.boatId) || null;
|
|
941
|
+
if (boatId && !getBoat(race, boatId)) return res.status(400).json({ error: 'No such boat' });
|
|
942
|
+
race.selfBoatId = boatId;
|
|
943
|
+
saveState();
|
|
944
|
+
res.json(raceWithEstimates(race));
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// Replaces the whole course in one save (matches the webapp's single
|
|
948
|
+
// "Save Course" form). Pass startLine/finishLine as `null` to clear a
|
|
949
|
+
// line, or a [pointA, pointB] pair to set one; marks is the full
|
|
950
|
+
// ordered replacement list.
|
|
951
|
+
router.put('/races/:id/course', (req, res) => {
|
|
952
|
+
const race = getRace(req.params.id);
|
|
953
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
954
|
+
const body = req.body || {};
|
|
955
|
+
ensureRaceShape(race);
|
|
956
|
+
|
|
957
|
+
let startLine = race.course.startLine;
|
|
958
|
+
if ('startLine' in body) {
|
|
959
|
+
const v = validateLine(body.startLine);
|
|
960
|
+
if (v === undefined) return res.status(400).json({ error: 'startLine must be null or [pointA, pointB]' });
|
|
961
|
+
startLine = v;
|
|
962
|
+
}
|
|
963
|
+
let finishLine = race.course.finishLine;
|
|
964
|
+
if ('finishLine' in body) {
|
|
965
|
+
const v = validateLine(body.finishLine);
|
|
966
|
+
if (v === undefined) return res.status(400).json({ error: 'finishLine must be null or [pointA, pointB]' });
|
|
967
|
+
finishLine = v;
|
|
968
|
+
}
|
|
969
|
+
let marks = race.course.marks;
|
|
970
|
+
if ('marks' in body) {
|
|
971
|
+
if (!Array.isArray(body.marks)) return res.status(400).json({ error: 'marks must be an array' });
|
|
972
|
+
const validated = [];
|
|
973
|
+
for (let i = 0; i < body.marks.length; i++) {
|
|
974
|
+
const p = validateCoordPoint(body.marks[i]);
|
|
975
|
+
if (!p) return res.status(400).json({ error: `mark ${i + 1} has an invalid lat/lon` });
|
|
976
|
+
validated.push({ id: (body.marks[i].id || `m${i}`).toString(), name: p.name || `Mark ${i + 1}`, lat: p.lat, lon: p.lon });
|
|
977
|
+
}
|
|
978
|
+
marks = validated;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
race.course = { startLine, marks, finishLine };
|
|
982
|
+
saveState();
|
|
983
|
+
publishCourseResources(race).catch(() => {});
|
|
984
|
+
res.json(raceWithEstimates(race));
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Boats are entered explicitly per race (not auto-populated from AIS) so
|
|
988
|
+
// the committee can add/remove exactly who's racing; the webapp still
|
|
989
|
+
// offers AIS and VET-register names as autocomplete suggestions when
|
|
990
|
+
// adding one.
|
|
991
|
+
router.post('/races/:id/boats', (req, res) => {
|
|
992
|
+
const race = getRace(req.params.id);
|
|
993
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
994
|
+
const name = ((req.body && req.body.name) || '').trim();
|
|
995
|
+
if (!name) {
|
|
996
|
+
return res.status(400).json({ error: 'name is required' });
|
|
997
|
+
}
|
|
998
|
+
const givenMmsi = ((req.body && req.body.mmsi) || '').toString().trim();
|
|
999
|
+
const mmsi = givenMmsi || lookupBoatRegistry(name);
|
|
1000
|
+
if (mmsi) upsertBoatRegistry(name, { mmsi });
|
|
1001
|
+
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
1002
|
+
// A remembered TCF only applies to boats outside VET — a VET-matched
|
|
1003
|
+
// boat should be picked fresh from the register's own dropdown rather
|
|
1004
|
+
// than silently carrying over a number from wherever it last raced.
|
|
1005
|
+
let tcf = defaultTcf;
|
|
1006
|
+
if (!isVetMatch(name)) {
|
|
1007
|
+
const remembered = getRegistryEntry(name);
|
|
1008
|
+
if (remembered && remembered.tcf != null) tcf = remembered.tcf;
|
|
1009
|
+
}
|
|
1010
|
+
const boat = {
|
|
1011
|
+
id: makeBoatId(),
|
|
1012
|
+
name,
|
|
1013
|
+
mmsi: mmsi || null,
|
|
1014
|
+
tcf,
|
|
1015
|
+
finishTime: null,
|
|
1016
|
+
track: [],
|
|
1017
|
+
dnf: false,
|
|
1018
|
+
dnfPosition: null
|
|
1019
|
+
};
|
|
1020
|
+
race.boats[boat.id] = boat;
|
|
1021
|
+
saveState();
|
|
1022
|
+
res.json(boat);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
router.delete('/races/:id/boats/:boatId', (req, res) => {
|
|
1026
|
+
const race = getRace(req.params.id);
|
|
1027
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1028
|
+
if (!getBoat(race, req.params.boatId)) return res.status(404).json({ error: 'No such boat' });
|
|
1029
|
+
delete race.boats[req.params.boatId];
|
|
1030
|
+
if (race.selfBoatId === req.params.boatId) race.selfBoatId = null;
|
|
1031
|
+
saveState();
|
|
1032
|
+
res.json({ ok: true });
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Sets (or, with finishTime: null, clears) a boat's finish time to an
|
|
1036
|
+
// arbitrary timestamp, so race committee can correct a mistimed click.
|
|
1037
|
+
router.put('/races/:id/boats/:boatId/finishTime', (req, res) => {
|
|
1038
|
+
const race = getRace(req.params.id);
|
|
1039
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1040
|
+
const boat = getBoat(race, req.params.boatId);
|
|
1041
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
1042
|
+
const raw = req.body ? req.body.finishTime : undefined;
|
|
1043
|
+
if (raw === null) {
|
|
1044
|
+
boat.finishTime = null;
|
|
1045
|
+
} else {
|
|
1046
|
+
const t = Number(raw);
|
|
1047
|
+
if (!isFinite(t) || t <= 0) {
|
|
1048
|
+
return res.status(400).json({ error: 'finishTime must be an epoch-millisecond timestamp or null' });
|
|
1049
|
+
}
|
|
1050
|
+
boat.finishTime = t;
|
|
1051
|
+
// A real finish supersedes a DNF (e.g. correcting a call-off that
|
|
1052
|
+
// caught a boat that had actually already crossed the line).
|
|
1053
|
+
boat.dnf = false;
|
|
1054
|
+
boat.dnfPosition = null;
|
|
1055
|
+
}
|
|
1056
|
+
saveState();
|
|
1057
|
+
res.json(boat);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// Marks (or, with dnf: false, un-marks) one boat DNF by hand, outside of
|
|
1061
|
+
// a full race call-off — e.g. a boat retires mid-race. Setting it also
|
|
1062
|
+
// clears any finish time and captures the boat's last known position.
|
|
1063
|
+
router.put('/races/:id/boats/:boatId/dnf', (req, res) => {
|
|
1064
|
+
const race = getRace(req.params.id);
|
|
1065
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1066
|
+
const boat = getBoat(race, req.params.boatId);
|
|
1067
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
1068
|
+
const dnf = !!(req.body && req.body.dnf);
|
|
1069
|
+
if (dnf) {
|
|
1070
|
+
boat.dnf = true;
|
|
1071
|
+
boat.finishTime = null;
|
|
1072
|
+
boat.dnfPosition = getLastKnownPosition(boat);
|
|
1073
|
+
} else {
|
|
1074
|
+
boat.dnf = false;
|
|
1075
|
+
boat.dnfPosition = null;
|
|
1076
|
+
}
|
|
1077
|
+
saveState();
|
|
1078
|
+
res.json(boat);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
router.put('/races/:id/boats/:boatId/mmsi', (req, res) => {
|
|
1082
|
+
const race = getRace(req.params.id);
|
|
1083
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1084
|
+
const boat = getBoat(race, req.params.boatId);
|
|
1085
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
1086
|
+
const mmsi = ((req.body && req.body.mmsi) || '').toString().trim();
|
|
1087
|
+
boat.mmsi = mmsi || null;
|
|
1088
|
+
if (boat.mmsi) upsertBoatRegistry(boat.name, { mmsi: boat.mmsi });
|
|
1089
|
+
saveState();
|
|
1090
|
+
res.json(boat);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
router.get('/boat-registry', (req, res) => {
|
|
1094
|
+
res.json({ boats: Object.values(state.boatRegistry) });
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Plugin config setting (Server -> Plugin Config), not per-race and not
|
|
1098
|
+
// writable from the webapp — the webapp only reads it to know whether to
|
|
1099
|
+
// offer the VET-tall register at all.
|
|
1100
|
+
router.get('/vet-enabled', (req, res) => {
|
|
1101
|
+
res.json({ enabled: isVetEnabled() });
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
|
|
1105
|
+
const race = getRace(req.params.id);
|
|
1106
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1107
|
+
const boat = getBoat(race, req.params.boatId);
|
|
1108
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
1109
|
+
const tcf = Number(req.body && req.body.tcf);
|
|
1110
|
+
if (!isFinite(tcf) || tcf <= 0) {
|
|
1111
|
+
return res.status(400).json({ error: 'tcf must be a positive number' });
|
|
1112
|
+
}
|
|
1113
|
+
boat.tcf = tcf;
|
|
1114
|
+
// Remembered across races only for boats outside VET — see the note
|
|
1115
|
+
// on upsertBoatRegistry/isVetMatch above.
|
|
1116
|
+
if (!isVetMatch(boat.name)) upsertBoatRegistry(boat.name, { tcf });
|
|
1117
|
+
saveState();
|
|
1118
|
+
res.json(boat);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
router.get('/handicap-source', async (req, res) => {
|
|
1122
|
+
try {
|
|
1123
|
+
const force = req.query.refresh === 'true' || req.query.refresh === '1';
|
|
1124
|
+
const data = await fetchHandicapBoats(force);
|
|
1125
|
+
res.json(data);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
res.status(502).json({ error: 'Could not load handicap register: ' + e.message });
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
return plugin;
|
|
1133
|
+
};
|