signalk-race-control 0.5.1 → 0.6.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/README.md +27 -12
- package/index.js +193 -36
- package/package.json +1 -1
- package/public/app.js +79 -23
- package/public/index.html +5 -1
package/README.md
CHANGED
|
@@ -49,12 +49,21 @@ and a live projected finishing order — during and after the race.
|
|
|
49
49
|
default** — turn it on in the plugin's settings (`vetEnabled`) for clubs that
|
|
50
50
|
actually race under VET-tall; see **Cross-race TCF memory** below for what happens
|
|
51
51
|
to handicaps while it's off.
|
|
52
|
-
- **
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
- **KTK import** — the same idea as VET-tall, for [Klassisk Treseiler
|
|
53
|
+
Klubb](http://klassisktreseilerklubb.blogspot.com/)'s (KTK) own KLR handicap
|
|
54
|
+
register, published each season as a table in a blog post — this plugin always
|
|
55
|
+
reads whichever post is most recent, so it tracks the current season without
|
|
56
|
+
needing an update. A KLR number isn't a TCF directly: corrected time is elapsed ×
|
|
57
|
+
(KLR / 100), so that conversion happens automatically. Off by default — turn it on
|
|
58
|
+
in the plugin's settings (`ktkEnabled`) for clubs that race under KLR. If a boat is
|
|
59
|
+
listed in **both** VET-tall and KTK, the dropdown shows alternatives from both
|
|
60
|
+
(labeled by which is which), rather than picking one register over the other.
|
|
61
|
+
- **Cross-race TCF memory for boats outside every register** — whenever a boat isn't
|
|
62
|
+
matched in VET-tall or KTK (or both are disabled), the TCF you set for it by hand is
|
|
63
|
+
remembered by boat name and applied automatically the next time a boat with that
|
|
64
|
+
name is added to any race. A boat matched in either register never has its TCF
|
|
65
|
+
carried over this way — it always starts from the default until you pick an
|
|
66
|
+
alternative from the dropdown or edit it again.
|
|
58
67
|
- **MMSI and sail number, remembered across races** — set once per boat (MMSI can
|
|
59
68
|
also be picked up automatically from a live AIS/self vessel with a matching name),
|
|
60
69
|
both are remembered in a small cross-race registry: add a boat with the same name
|
|
@@ -164,7 +173,10 @@ and a live projected finishing order — during and after the race.
|
|
|
164
173
|
download time and a **Refresh VET register** link lets the offline page re-fetch the
|
|
165
174
|
current sheet straight from Google Sheets (no plugin server needed for that — just
|
|
166
175
|
whatever internet connection the browser has), with the same alternatives dropdown
|
|
167
|
-
and autocomplete as the main webapp.
|
|
176
|
+
and autocomplete as the main webapp. If KTK is enabled too, its KLR numbers are
|
|
177
|
+
seeded in the same way, but — since KTK's own page doesn't allow that kind of direct
|
|
178
|
+
browser fetch — only as a snapshot from download time, with no offline refresh
|
|
179
|
+
equivalent. AIS boat names/positions, the course/chart, and
|
|
168
180
|
importing a fleet from Manage2Sail still need the live server (Manage2Sail's own API
|
|
169
181
|
doesn't allow browser-side fetches at all) — TCF is entered by hand for anything the
|
|
170
182
|
VET register doesn't cover. Also has its own "Download results as CSV" button.
|
|
@@ -236,11 +248,14 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
|
|
|
236
248
|
tracks for a fresh run.
|
|
237
249
|
- The VET-tall source page can be overridden in the plugin's settings
|
|
238
250
|
(`handicapSourceUrl`) — point it at a specific year's Google Sheet link directly to
|
|
239
|
-
skip the SSCA page lookup, e.g. if a club uses its own register.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
skip the SSCA page lookup, e.g. if a club uses its own register. KTK's source page
|
|
252
|
+
can likewise be overridden (`ktkSourceUrl`) — point it at a specific post directly to
|
|
253
|
+
skip the label-listing lookup.
|
|
254
|
+
- Whether the VET-tall register is used at all (`vetEnabled`, off by default) is also a
|
|
255
|
+
plugin setting, not a per-race or webapp-side option — same for KTK's KLR register
|
|
256
|
+
(`ktkEnabled`) — change either under **Server → Plugin Config → Race Control** and
|
|
257
|
+
restart the plugin (the server does this automatically on save) for the webapp to
|
|
258
|
+
pick it up.
|
|
244
259
|
- The mark-rounding radius used for the estimated finish time and remaining-distance
|
|
245
260
|
calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
|
|
246
261
|
loosen it for a fleet with noisier AIS tracks, or tighten it if marks sit close
|
package/index.js
CHANGED
|
@@ -5,6 +5,16 @@ const ExcelJS = require('exceljs');
|
|
|
5
5
|
const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
|
|
6
6
|
const HANDICAP_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
7
7
|
|
|
8
|
+
// KTK (Klassisk Treseiler Klubb) publishes its own handicap register, KLR,
|
|
9
|
+
// as an HTML table embedded directly in a Blogger post tagged "KLR" — the
|
|
10
|
+
// label listing page below always shows the most recent post first (with
|
|
11
|
+
// its full content, not just an excerpt), so it doubles as "whichever
|
|
12
|
+
// season's numbers are current" without needing to track individual post
|
|
13
|
+
// URLs across years, the same way the VET-tall page above always links to
|
|
14
|
+
// the current sheet. A specific post URL works too, since it has the exact
|
|
15
|
+
// same table.
|
|
16
|
+
const DEFAULT_KTK_SOURCE_PAGE = 'http://klassisktreseilerklubb.blogspot.com/search/label/KLR';
|
|
17
|
+
|
|
8
18
|
function parseCsvText(text) {
|
|
9
19
|
const rows = [];
|
|
10
20
|
let row = [];
|
|
@@ -99,6 +109,51 @@ function parseHandicapSheet(csvText) {
|
|
|
99
109
|
return boats;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
function stripHtmlToText(html) {
|
|
113
|
+
return html
|
|
114
|
+
.replace(/<[^>]*>/g, '')
|
|
115
|
+
.replace(/ /gi, ' ')
|
|
116
|
+
.replace(/&/gi, '&')
|
|
117
|
+
.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// KTK's KLR table has just two columns (boat name, KLR number) and no label
|
|
121
|
+
// text distinguishing multiple entries for the same boat (unlike VET-tall's
|
|
122
|
+
// named sail configurations) — a boat with more than one row just gets
|
|
123
|
+
// numbered alternatives. The published KLR number isn't a TCF directly:
|
|
124
|
+
// corrected time = elapsed × (KLR / 100), so that division happens here,
|
|
125
|
+
// once, rather than at every point KLR values get used downstream — same
|
|
126
|
+
// as VET-tall's own numbers, which likewise need no conversion once parsed.
|
|
127
|
+
function parseKtkHtml(html) {
|
|
128
|
+
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
|
129
|
+
if (!tableMatch) {
|
|
130
|
+
throw new Error('Could not find the KLR table on the page');
|
|
131
|
+
}
|
|
132
|
+
const rowMatches = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
|
133
|
+
const boatsByKey = new Map();
|
|
134
|
+
rowMatches.forEach((rowHtml) => {
|
|
135
|
+
const cellMatches = rowHtml.match(/<td[\s\S]*?<\/td>/gi) || [];
|
|
136
|
+
if (cellMatches.length < 2) return;
|
|
137
|
+
const name = stripHtmlToText(cellMatches[0]);
|
|
138
|
+
const rawValue = parseNorwegianNumber(stripHtmlToText(cellMatches[1]));
|
|
139
|
+
if (!name || rawValue == null || rawValue <= 0) return;
|
|
140
|
+
const key = name.toLowerCase();
|
|
141
|
+
const existing = boatsByKey.get(key) || { name, vets: [] };
|
|
142
|
+
// raw is the published KLR number itself (e.g. 166) — kept alongside
|
|
143
|
+
// the converted TCF (1.66) so the dropdown can show the number sailors
|
|
144
|
+
// actually recognize, not just the already-divided multiplier.
|
|
145
|
+
existing.vets.push({ label: '', value: Math.round((rawValue / 100) * 1000) / 1000, raw: rawValue });
|
|
146
|
+
boatsByKey.set(key, existing);
|
|
147
|
+
});
|
|
148
|
+
const boats = Array.from(boatsByKey.values());
|
|
149
|
+
boats.forEach((b) => {
|
|
150
|
+
b.vets.forEach((v, i) => {
|
|
151
|
+
v.label = b.vets.length > 1 ? `KLR ${i + 1}` : 'KLR';
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return boats;
|
|
155
|
+
}
|
|
156
|
+
|
|
102
157
|
function extractGoogleSheetId(text) {
|
|
103
158
|
const m = text.match(/docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
104
159
|
return m ? m[1] : null;
|
|
@@ -358,6 +413,10 @@ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
|
|
|
358
413
|
vetEnabled: !!vetOptions.vetEnabled,
|
|
359
414
|
handicapCsvUrl: vetOptions.handicapCsvUrl || null,
|
|
360
415
|
handicapBoats: vetOptions.handicapBoats || [],
|
|
416
|
+
// KTK has no live-refresh counterpart offline (see the route handler) —
|
|
417
|
+
// just this one-time snapshot from export time.
|
|
418
|
+
ktkEnabled: !!vetOptions.ktkEnabled,
|
|
419
|
+
ktkBoats: vetOptions.ktkBoats || [],
|
|
361
420
|
boats: Object.values(race.boats).map((b) => ({
|
|
362
421
|
id: b.id,
|
|
363
422
|
name: b.name,
|
|
@@ -382,6 +441,12 @@ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
|
|
|
382
441
|
// title, the iOS home-screen app title) — escaped the normal way for that
|
|
383
442
|
// context, distinct from the JSON escaping above.
|
|
384
443
|
const escapedRaceName = String(race.name || 'Race').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
444
|
+
const handicapNotes = [];
|
|
445
|
+
if (vetOptions.vetEnabled) handicapNotes.push('VET-tall handicaps can be refreshed here directly from the internet (see below)');
|
|
446
|
+
if (vetOptions.ktkEnabled) handicapNotes.push("KTK's KLR numbers are included as of download time, but can't be refreshed offline (no direct internet fetch support)");
|
|
447
|
+
const handicapNote = handicapNotes.length
|
|
448
|
+
? handicapNotes.join(', and ') + '. Importing a whole fleet from Manage2Sail still needs the live plugin server, though.'
|
|
449
|
+
: 'TCF is entered by hand.';
|
|
385
450
|
|
|
386
451
|
return `<!doctype html>
|
|
387
452
|
<html lang="en">
|
|
@@ -468,7 +533,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
468
533
|
<p class="offline-note">Standalone backup — works with no server connection to this plugin.
|
|
469
534
|
Everything you do here is saved in this browser only (reopen this same downloaded file
|
|
470
535
|
to continue). Boat names/positions from AIS and the course/chart aren't available
|
|
471
|
-
offline. ${
|
|
536
|
+
offline. ${handicapNote}</p>
|
|
472
537
|
<h2 id="raceName"></h2>
|
|
473
538
|
<div id="clock" class="clock">00:00:00</div>
|
|
474
539
|
<div class="controls">
|
|
@@ -503,7 +568,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
503
568
|
<th class="boat-name-col">Boat</th>
|
|
504
569
|
<th>Sail #</th>
|
|
505
570
|
<th>TCF</th>
|
|
506
|
-
<th id="vetAlternativesTh" hidden>
|
|
571
|
+
<th id="vetAlternativesTh" hidden>Handicap alternatives</th>
|
|
507
572
|
<th>Start time</th>
|
|
508
573
|
<th>Elapsed</th>
|
|
509
574
|
<th>Corrected</th>
|
|
@@ -548,8 +613,12 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
548
613
|
race.vetEnabled = !!seed.vetEnabled;
|
|
549
614
|
race.handicapCsvUrl = seed.handicapCsvUrl || null;
|
|
550
615
|
if (!Array.isArray(race.handicapBoats)) race.handicapBoats = seed.handicapBoats || [];
|
|
616
|
+
// KTK has no live refresh offline (see the seed's own comment above), so
|
|
617
|
+
// its snapshot only ever comes from the seed — always take it fresh.
|
|
618
|
+
race.ktkEnabled = !!seed.ktkEnabled;
|
|
619
|
+
race.ktkBoats = seed.ktkBoats || [];
|
|
551
620
|
} catch (e) {
|
|
552
|
-
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [] };
|
|
621
|
+
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [], ktkEnabled: false, ktkBoats: [] };
|
|
553
622
|
}
|
|
554
623
|
|
|
555
624
|
function save() {
|
|
@@ -689,7 +758,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
689
758
|
function rebuildAddBoatSuggestions() {
|
|
690
759
|
addBoatSuggestions.innerHTML = '';
|
|
691
760
|
var seen = {};
|
|
692
|
-
race.handicapBoats.forEach(function (b) {
|
|
761
|
+
race.handicapBoats.concat(race.ktkBoats).forEach(function (b) {
|
|
693
762
|
var key = b.name.toLowerCase();
|
|
694
763
|
if (seen[key]) return;
|
|
695
764
|
seen[key] = true;
|
|
@@ -724,25 +793,41 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
724
793
|
});
|
|
725
794
|
}
|
|
726
795
|
|
|
727
|
-
// Rebuilds a row's
|
|
728
|
-
// only called when
|
|
796
|
+
// Rebuilds a row's alternatives <select> from the current register(s) —
|
|
797
|
+
// only called when a register itself (re)loads, not every render tick.
|
|
798
|
+
// Merges VET-tall and KTK when both are enabled and a boat matches both,
|
|
799
|
+
// rather than picking one over the other.
|
|
729
800
|
function refreshVetAlternatives(row, boatName) {
|
|
730
|
-
var
|
|
801
|
+
var name = boatName.trim().toLowerCase();
|
|
802
|
+
var vetEntry = race.vetEnabled ? race.handicapBoats.find(function (h) { return h.name.toLowerCase() === name; }) : null;
|
|
803
|
+
var ktkEntry = race.ktkEnabled ? race.ktkBoats.find(function (h) { return h.name.toLowerCase() === name; }) : null;
|
|
804
|
+
var bothEnabled = race.vetEnabled && race.ktkEnabled;
|
|
805
|
+
var options = [];
|
|
806
|
+
if (vetEntry) {
|
|
807
|
+
vetEntry.vets.forEach(function (v) {
|
|
808
|
+
options.push({ value: v.value, text: (bothEnabled ? 'VET ' : '') + v.label + ': ' + v.value });
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (ktkEntry) {
|
|
812
|
+
ktkEntry.vets.forEach(function (v) {
|
|
813
|
+
options.push({ value: v.value, text: (bothEnabled ? 'KTK ' : '') + v.label + ': ' + v.raw + ' → ' + v.value });
|
|
814
|
+
});
|
|
815
|
+
}
|
|
731
816
|
row.vetSelect.innerHTML = '';
|
|
732
817
|
var placeholder = document.createElement('option');
|
|
733
818
|
placeholder.value = '';
|
|
734
|
-
placeholder.textContent =
|
|
819
|
+
placeholder.textContent = options.length ? 'Pick…' : 'No match';
|
|
735
820
|
row.vetSelect.appendChild(placeholder);
|
|
736
|
-
row.vetSelect.disabled = !
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
var notValid = /ikke/i.test(
|
|
745
|
-
row.vetBadge.textContent =
|
|
821
|
+
row.vetSelect.disabled = !options.length;
|
|
822
|
+
options.forEach(function (o) {
|
|
823
|
+
var opt = document.createElement('option');
|
|
824
|
+
opt.value = String(o.value);
|
|
825
|
+
opt.textContent = o.text;
|
|
826
|
+
row.vetSelect.appendChild(opt);
|
|
827
|
+
});
|
|
828
|
+
if (vetEntry) {
|
|
829
|
+
var notValid = /ikke/i.test(vetEntry.validity || '');
|
|
830
|
+
row.vetBadge.textContent = vetEntry.validity ? (notValid ? '⚠ ' + vetEntry.validity : vetEntry.validity) : '';
|
|
746
831
|
row.vetBadge.classList.toggle('warn', notValid);
|
|
747
832
|
} else {
|
|
748
833
|
row.vetBadge.textContent = '';
|
|
@@ -1002,7 +1087,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1002
1087
|
vetBadge.className = 'vet-badge';
|
|
1003
1088
|
var tdVet = document.createElement('td');
|
|
1004
1089
|
tdVet.className = 'vet-cell';
|
|
1005
|
-
tdVet.hidden = !race.vetEnabled;
|
|
1090
|
+
tdVet.hidden = !race.vetEnabled && !race.ktkEnabled;
|
|
1006
1091
|
tdVet.append(vetSelect, vetBadge);
|
|
1007
1092
|
|
|
1008
1093
|
var startTimeInput = document.createElement('input');
|
|
@@ -1189,7 +1274,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1189
1274
|
row.selfBtn.classList.toggle('active', isSelf);
|
|
1190
1275
|
if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
|
|
1191
1276
|
if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
|
|
1192
|
-
if (race.vetEnabled) {
|
|
1277
|
+
if (race.vetEnabled || race.ktkEnabled) {
|
|
1193
1278
|
if (row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
1194
1279
|
refreshVetAlternatives(row, b.name);
|
|
1195
1280
|
row.vetHandicapVersion = handicapVersion;
|
|
@@ -1289,8 +1374,9 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1289
1374
|
});
|
|
1290
1375
|
|
|
1291
1376
|
vetStatusLine.hidden = !race.vetEnabled;
|
|
1292
|
-
vetAlternativesTh.hidden = !race.vetEnabled;
|
|
1377
|
+
vetAlternativesTh.hidden = !race.vetEnabled && !race.ktkEnabled;
|
|
1293
1378
|
vetRefreshBtn.addEventListener('click', function () { loadHandicapRegister(true); });
|
|
1379
|
+
if (race.ktkEnabled) rebuildAddBoatSuggestions();
|
|
1294
1380
|
if (race.vetEnabled) {
|
|
1295
1381
|
rebuildAddBoatSuggestions();
|
|
1296
1382
|
if (race.handicapBoats.length) {
|
|
@@ -1336,6 +1422,18 @@ module.exports = function (app) {
|
|
|
1336
1422
|
'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.',
|
|
1337
1423
|
default: false
|
|
1338
1424
|
},
|
|
1425
|
+
ktkSourceUrl: {
|
|
1426
|
+
type: 'string',
|
|
1427
|
+
title:
|
|
1428
|
+
"KTK page to fetch the current season's KLR numbers from — either the label listing (whose most recent post is used) or a specific post directly",
|
|
1429
|
+
default: DEFAULT_KTK_SOURCE_PAGE
|
|
1430
|
+
},
|
|
1431
|
+
ktkEnabled: {
|
|
1432
|
+
type: 'boolean',
|
|
1433
|
+
title:
|
|
1434
|
+
"Use KTK's KLR register for autocomplete and per-boat handicap alternatives too, alongside VET-tall if that's also on — a boat listed in both shows alternatives from both. Corrected time from a KLR number is elapsed × (KLR / 100).",
|
|
1435
|
+
default: false
|
|
1436
|
+
},
|
|
1339
1437
|
raceImportEnabled: {
|
|
1340
1438
|
type: 'boolean',
|
|
1341
1439
|
title:
|
|
@@ -1362,6 +1460,7 @@ module.exports = function (app) {
|
|
|
1362
1460
|
const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
|
|
1363
1461
|
const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
|
|
1364
1462
|
let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
|
|
1463
|
+
let ktkCache = { fetchedAt: 0, boats: [], sourceUrl: null };
|
|
1365
1464
|
|
|
1366
1465
|
function loadState() {
|
|
1367
1466
|
try {
|
|
@@ -1405,20 +1504,29 @@ module.exports = function (app) {
|
|
|
1405
1504
|
return !!(plugin.options && plugin.options.vetEnabled === true);
|
|
1406
1505
|
}
|
|
1407
1506
|
|
|
1507
|
+
// Same pattern as vetEnabled, for KTK's KLR register.
|
|
1508
|
+
function isKtkEnabled() {
|
|
1509
|
+
return !!(plugin.options && plugin.options.ktkEnabled === true);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1408
1512
|
// Plugin config setting, same pattern as vetEnabled — off by default, not
|
|
1409
1513
|
// per-race, not writable from the webapp itself.
|
|
1410
1514
|
function isRaceImportEnabled() {
|
|
1411
1515
|
return !!(plugin.options && plugin.options.raceImportEnabled === true);
|
|
1412
1516
|
}
|
|
1413
1517
|
|
|
1414
|
-
// A boat only counts as "in
|
|
1415
|
-
//
|
|
1416
|
-
// cached) actually has a matching name — disabling
|
|
1417
|
-
// "outside
|
|
1418
|
-
|
|
1419
|
-
|
|
1518
|
+
// A boat only counts as "in a handicap register" for registry-TCF
|
|
1519
|
+
// purposes while that register is enabled and (whatever's currently
|
|
1520
|
+
// cached of) it actually has a matching name — disabling a register makes
|
|
1521
|
+
// every boat "outside" it for this purpose too, per the setting's whole
|
|
1522
|
+
// point. Checks both VET-tall and KTK, since a boat matched by either one
|
|
1523
|
+
// should be picked fresh from its own dropdown rather than carrying over
|
|
1524
|
+
// a remembered TCF.
|
|
1525
|
+
function isHandicapRegisterMatch(name) {
|
|
1420
1526
|
const n = (name || '').trim().toLowerCase();
|
|
1421
|
-
|
|
1527
|
+
if (isVetEnabled() && handicapCache.boats.some((b) => b.name.toLowerCase() === n)) return true;
|
|
1528
|
+
if (isKtkEnabled() && ktkCache.boats.some((b) => b.name.toLowerCase() === n)) return true;
|
|
1529
|
+
return false;
|
|
1422
1530
|
}
|
|
1423
1531
|
|
|
1424
1532
|
function saveState() {
|
|
@@ -1799,6 +1907,22 @@ module.exports = function (app) {
|
|
|
1799
1907
|
return handicapCache;
|
|
1800
1908
|
}
|
|
1801
1909
|
|
|
1910
|
+
async function fetchKtkBoats(forceRefresh) {
|
|
1911
|
+
const sourceUrl = (plugin.options && plugin.options.ktkSourceUrl) || DEFAULT_KTK_SOURCE_PAGE;
|
|
1912
|
+
const age = Date.now() - ktkCache.fetchedAt;
|
|
1913
|
+
if (!forceRefresh && ktkCache.sourceUrl === sourceUrl && age < HANDICAP_CACHE_TTL_MS) {
|
|
1914
|
+
return ktkCache;
|
|
1915
|
+
}
|
|
1916
|
+
const res = await fetch(sourceUrl);
|
|
1917
|
+
if (!res.ok) {
|
|
1918
|
+
throw new Error(`KTK page returned HTTP ${res.status}`);
|
|
1919
|
+
}
|
|
1920
|
+
const html = await res.text();
|
|
1921
|
+
const boats = parseKtkHtml(html);
|
|
1922
|
+
ktkCache = { fetchedAt: Date.now(), boats, sourceUrl };
|
|
1923
|
+
return ktkCache;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1802
1926
|
async function fetchManage2SailClasses(eventUrl) {
|
|
1803
1927
|
const res = await fetch(eventUrl);
|
|
1804
1928
|
if (!res.ok) {
|
|
@@ -1996,10 +2120,26 @@ module.exports = function (app) {
|
|
|
1996
2120
|
}
|
|
1997
2121
|
}
|
|
1998
2122
|
}
|
|
2123
|
+
// KTK's page has no CORS support (unlike the VET-tall CSV export), so
|
|
2124
|
+
// unlike handicapBoats above this is a one-time snapshot only — the
|
|
2125
|
+
// offline page can't refresh it live itself. Still seeded in, since a
|
|
2126
|
+
// snapshot from export time is better than nothing.
|
|
2127
|
+
let ktkBoats = [];
|
|
2128
|
+
if (isKtkEnabled()) {
|
|
2129
|
+
try {
|
|
2130
|
+
const data = await fetchKtkBoats();
|
|
2131
|
+
ktkBoats = data.boats;
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
// Leave ktkBoats empty — same best-effort spirit as the rest of
|
|
2134
|
+
// this route.
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
1999
2137
|
const html = buildOfflineTimerHtml(race, defaultTcf, {
|
|
2000
2138
|
vetEnabled: isVetEnabled(),
|
|
2001
2139
|
handicapCsvUrl,
|
|
2002
|
-
handicapBoats
|
|
2140
|
+
handicapBoats,
|
|
2141
|
+
ktkEnabled: isKtkEnabled(),
|
|
2142
|
+
ktkBoats
|
|
2003
2143
|
});
|
|
2004
2144
|
const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
|
|
2005
2145
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
@@ -2233,11 +2373,12 @@ module.exports = function (app) {
|
|
|
2233
2373
|
const sailNumber = givenSailNumber || (registryEntry && registryEntry.sailNumber) || null;
|
|
2234
2374
|
if (sailNumber) upsertBoatRegistry(name, { sailNumber });
|
|
2235
2375
|
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
2236
|
-
// A remembered TCF only applies to boats outside
|
|
2237
|
-
// boat should be picked fresh from the
|
|
2238
|
-
// than silently carrying over a
|
|
2376
|
+
// A remembered TCF only applies to boats outside every enabled
|
|
2377
|
+
// register — a matched boat should be picked fresh from the
|
|
2378
|
+
// register's own dropdown rather than silently carrying over a
|
|
2379
|
+
// number from wherever it last raced.
|
|
2239
2380
|
let tcf = defaultTcf;
|
|
2240
|
-
if (!
|
|
2381
|
+
if (!isHandicapRegisterMatch(name) && registryEntry && registryEntry.tcf != null) {
|
|
2241
2382
|
tcf = registryEntry.tcf;
|
|
2242
2383
|
}
|
|
2243
2384
|
const boat = {
|
|
@@ -2413,6 +2554,11 @@ module.exports = function (app) {
|
|
|
2413
2554
|
res.json({ enabled: isVetEnabled() });
|
|
2414
2555
|
});
|
|
2415
2556
|
|
|
2557
|
+
// Same pattern as vet-enabled, for KTK's KLR register.
|
|
2558
|
+
router.get('/ktk-enabled', (req, res) => {
|
|
2559
|
+
res.json({ enabled: isKtkEnabled() });
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2416
2562
|
// Lets the course editor offer existing SignalK waypoints (e.g. ones
|
|
2417
2563
|
// already placed on a chart plotter) as start/mark/finish positions,
|
|
2418
2564
|
// instead of only typing lat/lon by hand. Best-effort, same spirit as
|
|
@@ -2449,9 +2595,10 @@ module.exports = function (app) {
|
|
|
2449
2595
|
return res.status(400).json({ error: 'tcf must be a positive number' });
|
|
2450
2596
|
}
|
|
2451
2597
|
boat.tcf = tcf;
|
|
2452
|
-
// Remembered across races only for boats outside
|
|
2453
|
-
// on upsertBoatRegistry/
|
|
2454
|
-
|
|
2598
|
+
// Remembered across races only for boats outside every enabled
|
|
2599
|
+
// register — see the note on upsertBoatRegistry/isHandicapRegisterMatch
|
|
2600
|
+
// above.
|
|
2601
|
+
if (!isHandicapRegisterMatch(boat.name)) upsertBoatRegistry(boat.name, { tcf });
|
|
2455
2602
|
saveState();
|
|
2456
2603
|
res.json(boat);
|
|
2457
2604
|
});
|
|
@@ -2466,6 +2613,16 @@ module.exports = function (app) {
|
|
|
2466
2613
|
}
|
|
2467
2614
|
});
|
|
2468
2615
|
|
|
2616
|
+
router.get('/ktk-source', async (req, res) => {
|
|
2617
|
+
try {
|
|
2618
|
+
const force = req.query.refresh === 'true' || req.query.refresh === '1';
|
|
2619
|
+
const data = await fetchKtkBoats(force);
|
|
2620
|
+
res.json(data);
|
|
2621
|
+
} catch (e) {
|
|
2622
|
+
res.status(502).json({ error: 'Could not load KTK register: ' + e.message });
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2469
2626
|
// Plugin config setting (Server -> Plugin Config), same pattern as
|
|
2470
2627
|
// vet-enabled — the webapp only reads it to know whether to show the
|
|
2471
2628
|
// import section at all.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Joachim Bakke <github@heiamoss.com>",
|
package/public/app.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
let suggestionItems = []; // currently-shown filtered suggestions
|
|
12
12
|
let suggestionActiveIndex = -1;
|
|
13
13
|
let handicapBoats = [];
|
|
14
|
-
let handicapVersion = 0; // bumped each successful VET-register load
|
|
14
|
+
let handicapVersion = 0; // bumped each successful VET-register (or KTK) load
|
|
15
15
|
let vetEnabled = false; // plugin config setting (Server -> Plugin Config), read-only here; off by default
|
|
16
|
+
let ktkBoats = []; // [{name, vets: [{label, value}]}] from KTK's KLR register — same shape as handicapBoats
|
|
17
|
+
let ktkEnabled = false; // plugin config setting, same pattern as vetEnabled
|
|
16
18
|
let raceImportEnabled = false; // plugin config setting, same pattern as vetEnabled
|
|
17
19
|
let importEventId = null; // Manage2Sail event id from the last successful "Find Classes" lookup
|
|
18
20
|
let importClasses = []; // [{id, name}] from that same lookup
|
|
@@ -60,6 +62,9 @@
|
|
|
60
62
|
const vetStatusText = document.getElementById('vetStatusText');
|
|
61
63
|
const vetRefreshBtn = document.getElementById('vetRefreshBtn');
|
|
62
64
|
const vetAlternativesTh = document.getElementById('vetAlternativesTh');
|
|
65
|
+
const ktkStatusLine = document.getElementById('ktkStatusLine');
|
|
66
|
+
const ktkStatusText = document.getElementById('ktkStatusText');
|
|
67
|
+
const ktkRefreshBtn = document.getElementById('ktkRefreshBtn');
|
|
63
68
|
const addBoatRow = document.getElementById('addBoatRow');
|
|
64
69
|
const addBoatName = document.getElementById('addBoatName');
|
|
65
70
|
const addBoatBtn = document.getElementById('addBoatBtn');
|
|
@@ -225,6 +230,11 @@
|
|
|
225
230
|
vetStatusText.classList.toggle('error', !!isError);
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
function setKtkStatus(msg, isError) {
|
|
234
|
+
ktkStatusText.textContent = msg || '';
|
|
235
|
+
ktkStatusText.classList.toggle('error', !!isError);
|
|
236
|
+
}
|
|
237
|
+
|
|
228
238
|
// Vessels are only used as autocomplete suggestions when adding a boat
|
|
229
239
|
// (name + MMSI hint) — a race's boat list is otherwise entered explicitly,
|
|
230
240
|
// not auto-populated from AIS.
|
|
@@ -264,6 +274,7 @@
|
|
|
264
274
|
}
|
|
265
275
|
}
|
|
266
276
|
if (vetEnabled) handicapBoats.forEach((b) => upsert(b.name, ''));
|
|
277
|
+
if (ktkEnabled) ktkBoats.forEach((b) => upsert(b.name, ''));
|
|
267
278
|
boatRegistry.forEach((b) => upsert(b.name, b.mmsi));
|
|
268
279
|
vesselSuggestions.forEach((v) => upsert(v.name, v.mmsi));
|
|
269
280
|
nameSuggestionPool = Array.from(byName.values());
|
|
@@ -362,10 +373,23 @@
|
|
|
362
373
|
}
|
|
363
374
|
// When disabled, every trace of VET is removed from the webapp rather
|
|
364
375
|
// than shown as a disabled/placeholder state — the status line (register
|
|
365
|
-
// status text + refresh link)
|
|
366
|
-
// column
|
|
376
|
+
// status text + refresh link) disappears entirely. The "Handicap
|
|
377
|
+
// alternatives" column is shared with KTK, so it's only hidden once
|
|
378
|
+
// both are off — see loadKtkEnabled.
|
|
367
379
|
vetStatusLine.hidden = !vetEnabled;
|
|
368
|
-
vetAlternativesTh.hidden = !vetEnabled;
|
|
380
|
+
vetAlternativesTh.hidden = !vetEnabled && !ktkEnabled;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Same pattern as loadVetEnabled, for KTK's KLR register.
|
|
384
|
+
async function loadKtkEnabled() {
|
|
385
|
+
try {
|
|
386
|
+
const data = await fetchJSON(`${API}/ktk-enabled`);
|
|
387
|
+
ktkEnabled = data.enabled === true;
|
|
388
|
+
} catch (e) {
|
|
389
|
+
ktkEnabled = false;
|
|
390
|
+
}
|
|
391
|
+
ktkStatusLine.hidden = !ktkEnabled;
|
|
392
|
+
vetAlternativesTh.hidden = !vetEnabled && !ktkEnabled;
|
|
369
393
|
}
|
|
370
394
|
|
|
371
395
|
// Same pattern as loadVetEnabled — when disabled, the whole import
|
|
@@ -406,6 +430,20 @@
|
|
|
406
430
|
}
|
|
407
431
|
}
|
|
408
432
|
|
|
433
|
+
async function loadKtkRegister(force) {
|
|
434
|
+
if (!ktkEnabled) return;
|
|
435
|
+
try {
|
|
436
|
+
setKtkStatus('Loading KTK register…');
|
|
437
|
+
const data = await fetchJSON(`${API}/ktk-source${force ? '?refresh=true' : ''}`);
|
|
438
|
+
ktkBoats = data.boats || [];
|
|
439
|
+
handicapVersion++;
|
|
440
|
+
rebuildNameSuggestionPool();
|
|
441
|
+
setKtkStatus(`KTK register: ${ktkBoats.length} boats loaded.`);
|
|
442
|
+
} catch (e) {
|
|
443
|
+
setKtkStatus('Could not load KTK register: ' + e.message, true);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
409
447
|
async function loadRacesList() {
|
|
410
448
|
try {
|
|
411
449
|
const data = await fetchJSON(`${API}/races`);
|
|
@@ -1768,7 +1806,7 @@
|
|
|
1768
1806
|
vetBadge.className = 'vet-badge';
|
|
1769
1807
|
const tdVet = document.createElement('td');
|
|
1770
1808
|
tdVet.className = 'vet-cell';
|
|
1771
|
-
tdVet.hidden = !vetEnabled;
|
|
1809
|
+
tdVet.hidden = !vetEnabled && !ktkEnabled;
|
|
1772
1810
|
tdVet.append(vetSelect, vetBadge);
|
|
1773
1811
|
|
|
1774
1812
|
// A boat's own start time, for a staggered/pursuit start or to correct
|
|
@@ -1923,28 +1961,43 @@
|
|
|
1923
1961
|
};
|
|
1924
1962
|
}
|
|
1925
1963
|
|
|
1926
|
-
// Rebuilds a row's
|
|
1927
|
-
// only called when
|
|
1928
|
-
// so an open dropdown or mid-pick isn't disrupted every
|
|
1929
|
-
// called at all while vetEnabled
|
|
1930
|
-
// "
|
|
1964
|
+
// Rebuilds a row's handicap-alternatives <select> from the current
|
|
1965
|
+
// register(s) — only called when a register itself (re)loads, not every
|
|
1966
|
+
// render tick, so an open dropdown or mid-pick isn't disrupted every
|
|
1967
|
+
// second. Never called at all while both vetEnabled and ktkEnabled are
|
|
1968
|
+
// false (see the call site) — the "Handicap alternatives" column is
|
|
1969
|
+
// hidden entirely in that case. Merges VET-tall and KTK when both are
|
|
1970
|
+
// enabled and a boat matches both, rather than picking one over the
|
|
1971
|
+
// other.
|
|
1931
1972
|
function refreshVetAlternatives(row, boatName) {
|
|
1932
|
-
const
|
|
1973
|
+
const name = boatName.trim().toLowerCase();
|
|
1974
|
+
const vetEntry = vetEnabled ? handicapBoats.find((h) => h.name.toLowerCase() === name) : null;
|
|
1975
|
+
const ktkEntry = ktkEnabled ? ktkBoats.find((h) => h.name.toLowerCase() === name) : null;
|
|
1976
|
+
const bothEnabled = vetEnabled && ktkEnabled;
|
|
1977
|
+
const options = [];
|
|
1978
|
+
if (vetEntry) {
|
|
1979
|
+
vetEntry.vets.forEach((v) => options.push({ value: v.value, text: `${bothEnabled ? 'VET ' : ''}${v.label}: ${v.value}` }));
|
|
1980
|
+
}
|
|
1981
|
+
if (ktkEntry) {
|
|
1982
|
+
ktkEntry.vets.forEach((v) =>
|
|
1983
|
+
options.push({ value: v.value, text: `${bothEnabled ? 'KTK ' : ''}${v.label}: ${v.raw} → ${v.value}` })
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1933
1986
|
row.vetSelect.innerHTML = '';
|
|
1934
1987
|
const placeholder = document.createElement('option');
|
|
1935
1988
|
placeholder.value = '';
|
|
1936
|
-
placeholder.textContent =
|
|
1989
|
+
placeholder.textContent = options.length ? 'Pick…' : 'No match';
|
|
1937
1990
|
row.vetSelect.appendChild(placeholder);
|
|
1938
|
-
row.vetSelect.disabled = !
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
const notValid = /ikke/i.test(
|
|
1947
|
-
row.vetBadge.textContent =
|
|
1991
|
+
row.vetSelect.disabled = !options.length;
|
|
1992
|
+
options.forEach((o) => {
|
|
1993
|
+
const opt = document.createElement('option');
|
|
1994
|
+
opt.value = String(o.value);
|
|
1995
|
+
opt.textContent = o.text;
|
|
1996
|
+
row.vetSelect.appendChild(opt);
|
|
1997
|
+
});
|
|
1998
|
+
if (vetEntry) {
|
|
1999
|
+
const notValid = /ikke/i.test(vetEntry.validity || '');
|
|
2000
|
+
row.vetBadge.textContent = vetEntry.validity ? (notValid ? '⚠ ' + vetEntry.validity : vetEntry.validity) : '';
|
|
1948
2001
|
row.vetBadge.classList.toggle('warn', notValid);
|
|
1949
2002
|
} else {
|
|
1950
2003
|
row.vetBadge.textContent = '';
|
|
@@ -2093,7 +2146,7 @@
|
|
|
2093
2146
|
if (document.activeElement !== row.tcfInput) {
|
|
2094
2147
|
row.tcfInput.value = b.tcf;
|
|
2095
2148
|
}
|
|
2096
|
-
if (vetEnabled && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
2149
|
+
if ((vetEnabled || ktkEnabled) && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
2097
2150
|
refreshVetAlternatives(row, b.name);
|
|
2098
2151
|
row.vetHandicapVersion = handicapVersion;
|
|
2099
2152
|
}
|
|
@@ -2236,6 +2289,7 @@
|
|
|
2236
2289
|
scheduleCallOffBtn.addEventListener('click', scheduleCallOff);
|
|
2237
2290
|
cancelCallOffBtn.addEventListener('click', cancelCallOffSchedule);
|
|
2238
2291
|
vetRefreshBtn.addEventListener('click', () => loadHandicapRegister(true));
|
|
2292
|
+
ktkRefreshBtn.addEventListener('click', () => loadKtkRegister(true));
|
|
2239
2293
|
addBoatBtn.addEventListener('click', () => {
|
|
2240
2294
|
hideSuggestions();
|
|
2241
2295
|
addBoat();
|
|
@@ -2359,11 +2413,13 @@
|
|
|
2359
2413
|
|
|
2360
2414
|
async function init() {
|
|
2361
2415
|
await loadVetEnabled();
|
|
2416
|
+
await loadKtkEnabled();
|
|
2362
2417
|
await loadRaceImportEnabled();
|
|
2363
2418
|
await Promise.all([
|
|
2364
2419
|
loadVessels(),
|
|
2365
2420
|
loadBoatRegistry(),
|
|
2366
2421
|
loadHandicapRegister(false),
|
|
2422
|
+
loadKtkRegister(false),
|
|
2367
2423
|
loadWaypoints(),
|
|
2368
2424
|
loadRacesList(),
|
|
2369
2425
|
refreshSelfNav()
|
package/public/index.html
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
<span id="vetStatusText"></span>
|
|
56
56
|
<button id="vetRefreshBtn" class="link-btn">Refresh VET register</button>
|
|
57
57
|
</div>
|
|
58
|
+
<div id="ktkStatusLine" class="status vet-status">
|
|
59
|
+
<span id="ktkStatusText"></span>
|
|
60
|
+
<button id="ktkRefreshBtn" class="link-btn">Refresh KTK register</button>
|
|
61
|
+
</div>
|
|
58
62
|
</header>
|
|
59
63
|
<main>
|
|
60
64
|
<p id="noRacesMsg" class="empty" hidden>No races yet. Click "+ New Race" to plan one.</p>
|
|
@@ -151,7 +155,7 @@
|
|
|
151
155
|
<th>Sail #</th>
|
|
152
156
|
<th>MMSI</th>
|
|
153
157
|
<th>TCF</th>
|
|
154
|
-
<th id="vetAlternativesTh">
|
|
158
|
+
<th id="vetAlternativesTh">Handicap alternatives</th>
|
|
155
159
|
<th>Start time</th>
|
|
156
160
|
<th>Elapsed</th>
|
|
157
161
|
<th>Corrected</th>
|