signalk-race-control 0.4.1 → 0.4.3
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 +43 -12
- package/index.js +149 -27
- package/package.json +2 -1
- package/public/app.js +304 -7
- package/public/index.html +4 -0
- package/public/style.css +43 -1
package/README.md
CHANGED
|
@@ -49,10 +49,10 @@ and a live projected finishing order — during and after the race.
|
|
|
49
49
|
that name is added to any race. A boat that *is* VET-matched never has its TCF
|
|
50
50
|
carried over this way — it always starts from the default until you pick a VET
|
|
51
51
|
alternative or edit it again.
|
|
52
|
-
- **MMSI, remembered across races** — set once per boat (
|
|
53
|
-
from a live AIS/self vessel with a matching name),
|
|
54
|
-
cross-race registry: add a boat with the same name
|
|
55
|
-
|
|
52
|
+
- **MMSI and sail number, remembered across races** — set once per boat (MMSI can
|
|
53
|
+
also be picked up automatically from a live AIS/self vessel with a matching name),
|
|
54
|
+
both are remembered in a small cross-race registry: add a boat with the same name
|
|
55
|
+
in a later race and its MMSI and sail number fill in on their own.
|
|
56
56
|
- **Import a whole fleet from Manage2Sail** — off by default (`raceImportEnabled` in
|
|
57
57
|
the plugin's settings, alongside `vetEnabled`); once turned on, an **Import boats
|
|
58
58
|
from Manage2Sail** section lets you paste a Manage2Sail event URL, pick one or more
|
|
@@ -75,19 +75,46 @@ and a live projected finishing order — during and after the race.
|
|
|
75
75
|
**Clear** to undo. Handles races that cross midnight.
|
|
76
76
|
- **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
|
|
77
77
|
marks, and the finish line (or click **Use my position** if you're sitting at that
|
|
78
|
-
spot
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
|
|
79
|
+
already placed from a chart plotter — and pick one to fill in its position). The
|
|
80
|
+
webapp draws them on a built-in chart, overlaid with each AIS-tracked boat's
|
|
81
|
+
recorded track for the current race — drag the **replay** slider to step back
|
|
82
|
+
through it, or leave it on **Live**. Click **Play** to have it step through on its own
|
|
83
|
+
instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
|
|
84
|
+
from the earliest recorded position when played from Live (there's nothing to play
|
|
85
|
+
forward into from there), stops on its own once it catches up to the latest one, and
|
|
86
|
+
dragging the main slider or clicking **Live** stops it early. Every actual recorded
|
|
87
|
+
position (an AIS sample,
|
|
88
|
+
or a manually-recorded mark rounding) shows as a small dot along the track, so it's
|
|
89
|
+
clear which points are real; wherever the slider sits between two of them, the boat's
|
|
90
|
+
position is linearly interpolated and drawn as a hollow ring instead of a solid dot,
|
|
91
|
+
so an in-between position always reads as estimated rather than observed. It falls
|
|
92
|
+
back to just the last real position (no ring) instead of interpolating across a gap
|
|
93
|
+
longer than 5 minutes — AIS dropping out, or a mark rounding recorded far from any
|
|
94
|
+
real fix — since a straight line across a gap that long would be a guess, not an
|
|
95
|
+
estimate. The course is also published as SignalK
|
|
81
96
|
waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
|
|
82
|
-
standard resources API —
|
|
83
|
-
|
|
97
|
+
standard resources API — both directions (reading existing waypoints for the
|
|
98
|
+
autocomplete, and publishing the saved course) only do anything if your server has a
|
|
99
|
+
resources provider installed; they're a no-op otherwise, never a failure.
|
|
84
100
|
- **Estimated finish time & live rank** — while a boat is still racing, if it has a
|
|
85
101
|
live AIS position and speed and the race has a finish line, the plugin projects a
|
|
86
102
|
finish time and corrected time from its remaining distance and speed, and ranks it
|
|
87
103
|
accordingly. The distance routes around whichever marks the boat hasn't rounded yet
|
|
88
|
-
(detected automatically from its recorded track passing within
|
|
89
|
-
|
|
90
|
-
that detection actually
|
|
104
|
+
(detected automatically from its recorded track passing within a configurable radius
|
|
105
|
+
— `markRoundingRadiusM`, 100m by default — of each mark in order) rather than
|
|
106
|
+
cutting straight to the finish — accuracy still depends on that detection actually
|
|
107
|
+
catching each rounding. A rounding can also be recorded by hand: each boat's row has
|
|
108
|
+
a numbered pill per mark in the **Marks** column — click one to mark it rounded right
|
|
109
|
+
now, click again to clear it — for a boat with no MMSI/AIS at all, or to correct one
|
|
110
|
+
the automatic detection missed. Either source counts toward the same rounding, and
|
|
111
|
+
for a boat still racing with no AIS-based estimate to rank by, marks rounded (by
|
|
112
|
+
either method) takes priority over raw elapsed time, so a boat further round the
|
|
113
|
+
course still ranks ahead even without live tracking. A manual rounding also records a
|
|
114
|
+
position for the replay chart — the boat's live position if it has AIS, otherwise the
|
|
115
|
+
mark's own position (a reasonable stand-in, since rounding a mark means being at it)
|
|
116
|
+
— so even a boat with no AIS at all shows up on the chart at each mark it's recorded
|
|
117
|
+
rounding.
|
|
91
118
|
- **Stop / call off the race** — freezes elapsed/corrected time for everyone without
|
|
92
119
|
touching boats, finish times, or the course (unlike Reset, which clears the race
|
|
93
120
|
back to not-started), and marks every boat that hadn't finished as **DNF**,
|
|
@@ -200,3 +227,7 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
|
|
|
200
227
|
setting, not a per-race or webapp-side option — change it under
|
|
201
228
|
**Server → Plugin Config → Race Control** and restart the plugin (the server does
|
|
202
229
|
this automatically on save) for the webapp to pick it up.
|
|
230
|
+
- The mark-rounding radius used for the estimated finish time and remaining-distance
|
|
231
|
+
calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
|
|
232
|
+
loosen it for a fleet with noisier AIS tracks, or tighten it if marks sit close
|
|
233
|
+
together and a boat's radius circles are overlapping.
|
package/index.js
CHANGED
|
@@ -251,6 +251,7 @@ function ensureRaceShape(race) {
|
|
|
251
251
|
if (b.dnfPosition === undefined) b.dnfPosition = null;
|
|
252
252
|
if (b.startTime === undefined) b.startTime = null;
|
|
253
253
|
if (b.sailNumber === undefined) b.sailNumber = null;
|
|
254
|
+
if (!b.markTimes) b.markTimes = {};
|
|
254
255
|
});
|
|
255
256
|
}
|
|
256
257
|
|
|
@@ -1329,15 +1330,22 @@ module.exports = function (app) {
|
|
|
1329
1330
|
title:
|
|
1330
1331
|
'Allow importing a complete fleet (every boat, with TCF) into a race from an external regatta system — currently Manage2Sail. Off by default, since it fetches from a third-party site and bulk-adds boats.',
|
|
1331
1332
|
default: false
|
|
1333
|
+
},
|
|
1334
|
+
markRoundingRadiusM: {
|
|
1335
|
+
type: 'number',
|
|
1336
|
+
title:
|
|
1337
|
+
'How close (in meters) an AIS-tracked boat must come to a mark for it to count as rounded, for the estimated finish time and remaining-distance calculations',
|
|
1338
|
+
default: 100
|
|
1332
1339
|
}
|
|
1333
1340
|
}
|
|
1334
1341
|
};
|
|
1335
1342
|
|
|
1336
1343
|
// Races are kept by id so several can be planned ahead and reviewed after
|
|
1337
1344
|
// the fact, rather than a single race getting overwritten by Reset.
|
|
1338
|
-
// boatRegistry is a separate, cross-race name->MMSI/TCF
|
|
1339
|
-
// boat's MMSI or (for boats outside VET) TCF
|
|
1340
|
-
// applied automatically next time that name is
|
|
1345
|
+
// boatRegistry is a separate, cross-race name->MMSI/sail number/TCF
|
|
1346
|
+
// memory: once a boat's MMSI, sail number, or (for boats outside VET) TCF
|
|
1347
|
+
// is entered anywhere, it's applied automatically next time that name is
|
|
1348
|
+
// used in any race.
|
|
1341
1349
|
let state = { races: {}, order: [], currentRaceId: null, boatRegistry: {} };
|
|
1342
1350
|
let dataFile = null;
|
|
1343
1351
|
const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
|
|
@@ -1360,14 +1368,16 @@ module.exports = function (app) {
|
|
|
1360
1368
|
}
|
|
1361
1369
|
|
|
1362
1370
|
// Cross-race memory keyed by boat name, merging in whichever fields are
|
|
1363
|
-
// given (mmsi and/or tcf) without clobbering the
|
|
1364
|
-
// written here for boats outside the VET register — see the
|
|
1371
|
+
// given (mmsi, sailNumber, and/or tcf) without clobbering the others. tcf
|
|
1372
|
+
// is only ever written here for boats outside the VET register — see the
|
|
1373
|
+
// callers.
|
|
1365
1374
|
function upsertBoatRegistry(name, fields) {
|
|
1366
1375
|
if (!name) return;
|
|
1367
1376
|
const key = name.trim().toLowerCase();
|
|
1368
1377
|
const existing = state.boatRegistry[key] || { name: name.trim() };
|
|
1369
1378
|
existing.name = name.trim();
|
|
1370
1379
|
if (fields.mmsi) existing.mmsi = String(fields.mmsi).trim();
|
|
1380
|
+
if (fields.sailNumber) existing.sailNumber = String(fields.sailNumber).trim();
|
|
1371
1381
|
if (fields.tcf != null) existing.tcf = fields.tcf;
|
|
1372
1382
|
state.boatRegistry[key] = existing;
|
|
1373
1383
|
}
|
|
@@ -1376,11 +1386,6 @@ module.exports = function (app) {
|
|
|
1376
1386
|
return state.boatRegistry[(name || '').trim().toLowerCase()] || null;
|
|
1377
1387
|
}
|
|
1378
1388
|
|
|
1379
|
-
function lookupBoatRegistry(name) {
|
|
1380
|
-
const entry = getRegistryEntry(name);
|
|
1381
|
-
return entry ? entry.mmsi : null;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
1389
|
// vetEnabled is a plugin config setting (Server -> Plugin Config), not
|
|
1385
1390
|
// per-race and not editable from the webapp itself. Off by default — a
|
|
1386
1391
|
// fresh install (or one that's never touched this setting) treats every
|
|
@@ -1455,29 +1460,58 @@ module.exports = function (app) {
|
|
|
1455
1460
|
}
|
|
1456
1461
|
|
|
1457
1462
|
// A mark counts as "rounded" once the boat's recorded track came within
|
|
1458
|
-
// this radius of it
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
|
|
1463
|
+
// this radius of it — meters, configurable (plugin config,
|
|
1464
|
+
// markRoundingRadiusM), converted to nautical miles for distanceNm().
|
|
1465
|
+
// 100m by default — loose enough to tolerate AIS position jitter and the
|
|
1466
|
+
// 15s sampling gap (a boat doing 7kn covers about 55m between samples)
|
|
1467
|
+
// without needing an exact pass.
|
|
1468
|
+
function markRoundingRadiusNm() {
|
|
1469
|
+
const meters = (plugin.options && plugin.options.markRoundingRadiusM) || 100;
|
|
1470
|
+
return meters / 1852;
|
|
1471
|
+
}
|
|
1462
1472
|
|
|
1463
1473
|
// Scans a boat's recorded track chronologically, advancing to the next
|
|
1464
1474
|
// mark each time the track comes within rounding radius of the current
|
|
1465
1475
|
// one — so it naturally requires marks to be rounded in course order.
|
|
1466
1476
|
// Automatic (no manual "boat X rounded mark Y" input) by design, at the
|
|
1467
1477
|
// cost of missing a rounding if a boat cuts far outside the radius.
|
|
1468
|
-
function
|
|
1478
|
+
function countAutoRoundedMarks(race, boat) {
|
|
1469
1479
|
const marks = race.course.marks;
|
|
1470
1480
|
if (!marks.length || !boat.track || !boat.track.length) return 0;
|
|
1481
|
+
const radiusNm = markRoundingRadiusNm();
|
|
1471
1482
|
let markIdx = 0;
|
|
1472
1483
|
for (const pt of boat.track) {
|
|
1473
1484
|
if (markIdx >= marks.length) break;
|
|
1474
|
-
if (distanceNm(pt, marks[markIdx]) <=
|
|
1485
|
+
if (distanceNm(pt, marks[markIdx]) <= radiusNm) {
|
|
1475
1486
|
markIdx++;
|
|
1476
1487
|
}
|
|
1477
1488
|
}
|
|
1478
1489
|
return markIdx;
|
|
1479
1490
|
}
|
|
1480
1491
|
|
|
1492
|
+
// A committee member can also record a mark rounding by hand (with its
|
|
1493
|
+
// own timestamp) — for a boat with no MMSI/AIS at all, or to correct a
|
|
1494
|
+
// rounding the automatic track-based detection above missed. Counted the
|
|
1495
|
+
// same "in order" way as the automatic detection: a mark only counts if
|
|
1496
|
+
// every mark before it is also recorded, manually or automatically, so a
|
|
1497
|
+
// rounding can't be marked out of course order.
|
|
1498
|
+
function countManualRoundedMarks(race, boat) {
|
|
1499
|
+
const marks = race.course.marks;
|
|
1500
|
+
if (!marks.length || !boat.markTimes) return 0;
|
|
1501
|
+
let count = 0;
|
|
1502
|
+
for (const m of marks) {
|
|
1503
|
+
if (boat.markTimes[m.id] == null) break;
|
|
1504
|
+
count++;
|
|
1505
|
+
}
|
|
1506
|
+
return count;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// The higher of the two — a boat only needs one working way to record a
|
|
1510
|
+
// rounding, not both.
|
|
1511
|
+
function countRoundedMarks(race, boat) {
|
|
1512
|
+
return Math.max(countAutoRoundedMarks(race, boat), countManualRoundedMarks(race, boat));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1481
1515
|
// Distance from the boat's current position, around each remaining mark
|
|
1482
1516
|
// in order, to the finish line — not just a straight line to the finish.
|
|
1483
1517
|
// Returns null if there's nothing left to route to (no remaining marks and
|
|
@@ -1525,12 +1559,16 @@ module.exports = function (app) {
|
|
|
1525
1559
|
};
|
|
1526
1560
|
}
|
|
1527
1561
|
|
|
1528
|
-
// Attaches a live `estimate` to each unfinished
|
|
1529
|
-
// stored race —
|
|
1562
|
+
// Attaches a live `estimate` and `roundedMarksCount` to each unfinished
|
|
1563
|
+
// boat without mutating the stored race — both are derived from live/
|
|
1564
|
+
// recorded data, never persisted as such (roundedMarksCount is derived
|
|
1565
|
+
// from markTimes, which is persisted; the count itself isn't).
|
|
1530
1566
|
function raceWithEstimates(race) {
|
|
1531
1567
|
const out = JSON.parse(JSON.stringify(race));
|
|
1532
1568
|
Object.values(out.boats).forEach((b) => {
|
|
1533
|
-
|
|
1569
|
+
const boat = race.boats[b.id];
|
|
1570
|
+
b.estimate = estimateFinish(race, boat);
|
|
1571
|
+
b.roundedMarksCount = countRoundedMarks(race, boat);
|
|
1534
1572
|
});
|
|
1535
1573
|
return out;
|
|
1536
1574
|
}
|
|
@@ -1548,13 +1586,24 @@ module.exports = function (app) {
|
|
|
1548
1586
|
const tcf = boat.tcf != null ? boat.tcf : 1.0;
|
|
1549
1587
|
const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
|
|
1550
1588
|
const estimate = estimateFinish(race, boat);
|
|
1589
|
+
const roundedMarksCount = countRoundedMarks(race, boat);
|
|
1551
1590
|
const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
|
|
1552
|
-
return { boat, elapsedMs, correctedMs, estimate, rankMs };
|
|
1591
|
+
return { boat, elapsedMs, correctedMs, estimate, roundedMarksCount, rankMs };
|
|
1553
1592
|
})
|
|
1554
1593
|
.sort((a, b) => {
|
|
1555
1594
|
if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
|
|
1556
1595
|
if (a.rankMs == null) return 1;
|
|
1557
1596
|
if (b.rankMs == null) return -1;
|
|
1597
|
+
// Among still-racing boats with no AIS-based ETA to fall back on
|
|
1598
|
+
// (elapsed-so-far alone says nothing about how much course is
|
|
1599
|
+
// left), a boat recorded further around the course — manually or
|
|
1600
|
+
// automatically — ranks ahead regardless of corrected time so far.
|
|
1601
|
+
// Boats WITH an estimate already have marks-remaining baked into
|
|
1602
|
+
// estCorrectedMs via the distance routing, so this only applies
|
|
1603
|
+
// when neither side has one.
|
|
1604
|
+
if (!a.boat.finishTime && !b.boat.finishTime && !a.estimate && !b.estimate && a.roundedMarksCount !== b.roundedMarksCount) {
|
|
1605
|
+
return b.roundedMarksCount - a.roundedMarksCount;
|
|
1606
|
+
}
|
|
1558
1607
|
return a.rankMs - b.rankMs;
|
|
1559
1608
|
});
|
|
1560
1609
|
}
|
|
@@ -2165,29 +2214,33 @@ module.exports = function (app) {
|
|
|
2165
2214
|
if (!name) {
|
|
2166
2215
|
return res.status(400).json({ error: 'name is required' });
|
|
2167
2216
|
}
|
|
2217
|
+
const registryEntry = getRegistryEntry(name);
|
|
2168
2218
|
const givenMmsi = ((req.body && req.body.mmsi) || '').toString().trim();
|
|
2169
|
-
const mmsi = givenMmsi ||
|
|
2219
|
+
const mmsi = givenMmsi || (registryEntry && registryEntry.mmsi) || null;
|
|
2170
2220
|
if (mmsi) upsertBoatRegistry(name, { mmsi });
|
|
2221
|
+
const givenSailNumber = ((req.body && req.body.sailNumber) || '').toString().trim();
|
|
2222
|
+
const sailNumber = givenSailNumber || (registryEntry && registryEntry.sailNumber) || null;
|
|
2223
|
+
if (sailNumber) upsertBoatRegistry(name, { sailNumber });
|
|
2171
2224
|
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
2172
2225
|
// A remembered TCF only applies to boats outside VET — a VET-matched
|
|
2173
2226
|
// boat should be picked fresh from the register's own dropdown rather
|
|
2174
2227
|
// than silently carrying over a number from wherever it last raced.
|
|
2175
2228
|
let tcf = defaultTcf;
|
|
2176
|
-
if (!isVetMatch(name)) {
|
|
2177
|
-
|
|
2178
|
-
if (remembered && remembered.tcf != null) tcf = remembered.tcf;
|
|
2229
|
+
if (!isVetMatch(name) && registryEntry && registryEntry.tcf != null) {
|
|
2230
|
+
tcf = registryEntry.tcf;
|
|
2179
2231
|
}
|
|
2180
2232
|
const boat = {
|
|
2181
2233
|
id: makeBoatId(),
|
|
2182
2234
|
name,
|
|
2183
2235
|
mmsi: mmsi || null,
|
|
2184
|
-
sailNumber
|
|
2236
|
+
sailNumber,
|
|
2185
2237
|
tcf,
|
|
2186
2238
|
finishTime: null,
|
|
2187
2239
|
startTime: null,
|
|
2188
2240
|
track: [],
|
|
2189
2241
|
dnf: false,
|
|
2190
|
-
dnfPosition: null
|
|
2242
|
+
dnfPosition: null,
|
|
2243
|
+
markTimes: {}
|
|
2191
2244
|
};
|
|
2192
2245
|
race.boats[boat.id] = boat;
|
|
2193
2246
|
saveState();
|
|
@@ -2273,6 +2326,47 @@ module.exports = function (app) {
|
|
|
2273
2326
|
res.json(boat);
|
|
2274
2327
|
});
|
|
2275
2328
|
|
|
2329
|
+
// Manual mark-rounding: a committee member records (or clears) the
|
|
2330
|
+
// moment a boat rounded a specific mark, independent of the automatic
|
|
2331
|
+
// AIS track-based detection — for a boat with no MMSI/AIS at all, or to
|
|
2332
|
+
// correct a rounding the automatic detection missed. See
|
|
2333
|
+
// countManualRoundedMarks/countRoundedMarks for how the two combine.
|
|
2334
|
+
router.put('/races/:id/boats/:boatId/markTimes/:markId', (req, res) => {
|
|
2335
|
+
const race = getRace(req.params.id);
|
|
2336
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
2337
|
+
const boat = getBoat(race, req.params.boatId);
|
|
2338
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
2339
|
+
const mark = race.course.marks.find((m) => m.id === req.params.markId);
|
|
2340
|
+
if (!mark) return res.status(404).json({ error: 'No such mark' });
|
|
2341
|
+
if (!boat.track) boat.track = [];
|
|
2342
|
+
// Drop any earlier manually-added point for this exact mark first,
|
|
2343
|
+
// whether we're about to replace it or just clearing — tagged with
|
|
2344
|
+
// markId so this can never touch a genuine AIS-recorded sample.
|
|
2345
|
+
boat.track = boat.track.filter((pt) => pt.markId !== mark.id);
|
|
2346
|
+
const raw = req.body ? req.body.time : undefined;
|
|
2347
|
+
if (raw === null) {
|
|
2348
|
+
delete boat.markTimes[mark.id];
|
|
2349
|
+
} else {
|
|
2350
|
+
const t = Number(raw);
|
|
2351
|
+
if (!isFinite(t) || t <= 0) {
|
|
2352
|
+
return res.status(400).json({ error: 'time must be an epoch-millisecond timestamp or null' });
|
|
2353
|
+
}
|
|
2354
|
+
boat.markTimes[mark.id] = t;
|
|
2355
|
+
// Record where the boat was at that moment too — its live position
|
|
2356
|
+
// if we have one (accurate, and consistent with its recorded AIS
|
|
2357
|
+
// track), otherwise the mark's own position as a reasonable stand-
|
|
2358
|
+
// in (rounding a mark means being at it). Either way this is what
|
|
2359
|
+
// lets a manually-recorded rounding show up on the replay chart,
|
|
2360
|
+
// even for a boat with no AIS at all.
|
|
2361
|
+
const live = getLivePosition(boat.mmsi);
|
|
2362
|
+
const pos = live || { lat: mark.lat, lon: mark.lon };
|
|
2363
|
+
boat.track.push({ t, lat: pos.lat, lon: pos.lon, markId: mark.id });
|
|
2364
|
+
boat.track.sort((a, b) => a.t - b.t);
|
|
2365
|
+
}
|
|
2366
|
+
saveState();
|
|
2367
|
+
res.json(boat);
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2276
2370
|
router.put('/races/:id/boats/:boatId/mmsi', (req, res) => {
|
|
2277
2371
|
const race = getRace(req.params.id);
|
|
2278
2372
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
@@ -2292,6 +2386,7 @@ module.exports = function (app) {
|
|
|
2292
2386
|
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
2293
2387
|
const sailNumber = ((req.body && req.body.sailNumber) || '').toString().trim();
|
|
2294
2388
|
boat.sailNumber = sailNumber || null;
|
|
2389
|
+
if (boat.sailNumber) upsertBoatRegistry(boat.name, { sailNumber: boat.sailNumber });
|
|
2295
2390
|
saveState();
|
|
2296
2391
|
res.json(boat);
|
|
2297
2392
|
});
|
|
@@ -2307,6 +2402,32 @@ module.exports = function (app) {
|
|
|
2307
2402
|
res.json({ enabled: isVetEnabled() });
|
|
2308
2403
|
});
|
|
2309
2404
|
|
|
2405
|
+
// Lets the course editor offer existing SignalK waypoints (e.g. ones
|
|
2406
|
+
// already placed on a chart plotter) as start/mark/finish positions,
|
|
2407
|
+
// instead of only typing lat/lon by hand. Best-effort, same spirit as
|
|
2408
|
+
// publishCourseResources — silently returns none if this server has no
|
|
2409
|
+
// resources provider registered, rather than failing the whole course
|
|
2410
|
+
// editor over it.
|
|
2411
|
+
router.get('/waypoints', async (req, res) => {
|
|
2412
|
+
if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') {
|
|
2413
|
+
return res.json({ waypoints: [] });
|
|
2414
|
+
}
|
|
2415
|
+
try {
|
|
2416
|
+
const data = await app.resourcesApi.listResources('waypoints', {});
|
|
2417
|
+
const waypoints = Object.keys(data || {})
|
|
2418
|
+
.map((id) => {
|
|
2419
|
+
const r = data[id] || {};
|
|
2420
|
+
const coords = r.feature && r.feature.geometry && r.feature.geometry.coordinates;
|
|
2421
|
+
if (!Array.isArray(coords) || coords.length < 2) return null;
|
|
2422
|
+
return { id, name: r.name || id, lon: coords[0], lat: coords[1] };
|
|
2423
|
+
})
|
|
2424
|
+
.filter(Boolean);
|
|
2425
|
+
res.json({ waypoints });
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
res.json({ waypoints: [] });
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2310
2431
|
router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
|
|
2311
2432
|
const race = getRace(req.params.id);
|
|
2312
2433
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
@@ -2426,7 +2547,8 @@ module.exports = function (app) {
|
|
|
2426
2547
|
startTime: null,
|
|
2427
2548
|
track: [],
|
|
2428
2549
|
dnf: false,
|
|
2429
|
-
dnfPosition: null
|
|
2550
|
+
dnfPosition: null,
|
|
2551
|
+
markTimes: {}
|
|
2430
2552
|
};
|
|
2431
2553
|
race.boats[boat.id] = boat;
|
|
2432
2554
|
existingByName.set(key, boat);
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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
|
+
"author": "Joachim Bakke <github@heiamoss.com>",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"signalk-node-server-plugin",
|
|
8
9
|
"signalk-webapp",
|
package/public/app.js
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
let lastCourseFormRaceId = undefined; // tracks which race the course form reflects
|
|
25
25
|
let replayLive = true;
|
|
26
26
|
let replayTime = Date.now();
|
|
27
|
+
let replayPlaying = false;
|
|
28
|
+
let replaySpeed = 10; // 1x-60x, simulated seconds of replay time per real second
|
|
29
|
+
let replayPlayTimer = null;
|
|
30
|
+
let replayPlayLastTick = null;
|
|
31
|
+
let waypoints = []; // [{id, name, lat, lon}] from SignalK resources, for the course editor's "Pick a waypoint" dropdown
|
|
27
32
|
|
|
28
33
|
const raceSelect = document.getElementById('raceSelect');
|
|
29
34
|
const newRaceBtn = document.getElementById('newRaceBtn');
|
|
@@ -76,6 +81,9 @@
|
|
|
76
81
|
const replayControls = document.getElementById('replayControls');
|
|
77
82
|
const replaySlider = document.getElementById('replaySlider');
|
|
78
83
|
const replayTimeLabel = document.getElementById('replayTimeLabel');
|
|
84
|
+
const replayPlayBtn = document.getElementById('replayPlayBtn');
|
|
85
|
+
const replaySpeedSlider = document.getElementById('replaySpeedSlider');
|
|
86
|
+
const replaySpeedLabel = document.getElementById('replaySpeedLabel');
|
|
79
87
|
const replayLiveBtn = document.getElementById('replayLiveBtn');
|
|
80
88
|
const raceImportSection = document.getElementById('raceImportSection');
|
|
81
89
|
const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
|
|
@@ -363,6 +371,19 @@
|
|
|
363
371
|
}
|
|
364
372
|
}
|
|
365
373
|
|
|
374
|
+
// Best-effort, like the underlying resourcesApi calls it wraps — an empty
|
|
375
|
+
// list (no resources provider registered, or the request fails) just
|
|
376
|
+
// means the course editor's "Pick a waypoint" dropdowns have nothing to
|
|
377
|
+
// offer, not a failure worth surfacing.
|
|
378
|
+
async function loadWaypoints() {
|
|
379
|
+
try {
|
|
380
|
+
const data = await fetchJSON(`${API}/waypoints`);
|
|
381
|
+
waypoints = data.waypoints || [];
|
|
382
|
+
} catch (e) {
|
|
383
|
+
waypoints = [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
366
387
|
async function loadHandicapRegister(force) {
|
|
367
388
|
if (!vetEnabled) return;
|
|
368
389
|
try {
|
|
@@ -408,6 +429,11 @@
|
|
|
408
429
|
if (id !== lastCourseFormRaceId) {
|
|
409
430
|
loadCourseFormFromRace();
|
|
410
431
|
lastCourseFormRaceId = id;
|
|
432
|
+
// A running replay is specific to whichever race's track it was
|
|
433
|
+
// playing through — switching races (or to none) leaves it with
|
|
434
|
+
// nothing sensible to keep advancing into.
|
|
435
|
+
stopReplayPlayback();
|
|
436
|
+
replayLive = true;
|
|
411
437
|
}
|
|
412
438
|
}
|
|
413
439
|
|
|
@@ -757,6 +783,28 @@
|
|
|
757
783
|
}
|
|
758
784
|
}
|
|
759
785
|
|
|
786
|
+
// Records (or, with ts: null, clears) a boat's own manually-entered
|
|
787
|
+
// rounding time for one specific mark — independent of the automatic
|
|
788
|
+
// AIS-track-based detection, for a boat with no MMSI/AIS at all or to
|
|
789
|
+
// correct a rounding the automatic detection missed.
|
|
790
|
+
async function setMarkTime(boatId, markId, ts) {
|
|
791
|
+
if (!activeRaceId) return;
|
|
792
|
+
try {
|
|
793
|
+
const boat = await fetchJSON(
|
|
794
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/markTimes/${encodeURIComponent(markId)}`,
|
|
795
|
+
{
|
|
796
|
+
method: 'PUT',
|
|
797
|
+
headers: { 'Content-Type': 'application/json' },
|
|
798
|
+
body: JSON.stringify({ time: ts })
|
|
799
|
+
}
|
|
800
|
+
);
|
|
801
|
+
raceState.boats[boatId] = boat;
|
|
802
|
+
render();
|
|
803
|
+
} catch (e) {
|
|
804
|
+
setStatus(e.message, true);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
760
808
|
async function setMmsi(boatId, mmsi) {
|
|
761
809
|
if (!activeRaceId) return;
|
|
762
810
|
try {
|
|
@@ -829,18 +877,119 @@
|
|
|
829
877
|
return null;
|
|
830
878
|
}
|
|
831
879
|
|
|
880
|
+
// Generic substring-match autocomplete, wiring `input` to `dropdown` (a
|
|
881
|
+
// hidden .suggestions element already sitting next to it in the DOM) —
|
|
882
|
+
// same matching/keyboard-nav behavior as the boat-name autocomplete
|
|
883
|
+
// above, but with its own closure-local item/selection state so several
|
|
884
|
+
// instances (one per course point row) can coexist without stepping on
|
|
885
|
+
// each other the way sharing the boat autocomplete's module-level state
|
|
886
|
+
// would.
|
|
887
|
+
function attachAutocomplete(input, dropdown, getPool, onSelect) {
|
|
888
|
+
let items = [];
|
|
889
|
+
let activeIndex = -1;
|
|
890
|
+
|
|
891
|
+
function hide() {
|
|
892
|
+
dropdown.hidden = true;
|
|
893
|
+
dropdown.innerHTML = '';
|
|
894
|
+
items = [];
|
|
895
|
+
activeIndex = -1;
|
|
896
|
+
}
|
|
897
|
+
function renderActive() {
|
|
898
|
+
Array.from(dropdown.children).forEach((el, i) => el.classList.toggle('active', i === activeIndex));
|
|
899
|
+
}
|
|
900
|
+
function select(item) {
|
|
901
|
+
onSelect(item);
|
|
902
|
+
hide();
|
|
903
|
+
}
|
|
904
|
+
function showFor(query) {
|
|
905
|
+
const q = query.trim().toLowerCase();
|
|
906
|
+
if (!q) {
|
|
907
|
+
hide();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const matches = getPool()
|
|
911
|
+
.filter((it) => it.name.toLowerCase().includes(q))
|
|
912
|
+
.slice(0, 20);
|
|
913
|
+
items = matches;
|
|
914
|
+
activeIndex = -1;
|
|
915
|
+
if (!matches.length) {
|
|
916
|
+
hide();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
dropdown.innerHTML = '';
|
|
920
|
+
matches.forEach((item) => {
|
|
921
|
+
const div = document.createElement('div');
|
|
922
|
+
div.className = 'suggestion-item';
|
|
923
|
+
const idx = item.name.toLowerCase().indexOf(q);
|
|
924
|
+
if (idx === -1) {
|
|
925
|
+
div.textContent = item.name;
|
|
926
|
+
} else {
|
|
927
|
+
div.innerHTML =
|
|
928
|
+
escapeHtml(item.name.slice(0, idx)) +
|
|
929
|
+
'<mark>' +
|
|
930
|
+
escapeHtml(item.name.slice(idx, idx + q.length)) +
|
|
931
|
+
'</mark>' +
|
|
932
|
+
escapeHtml(item.name.slice(idx + q.length));
|
|
933
|
+
}
|
|
934
|
+
// mousedown (not click) fires before the input's blur, same reason
|
|
935
|
+
// as the boat-name suggestions above.
|
|
936
|
+
div.addEventListener('mousedown', (e) => {
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
select(item);
|
|
939
|
+
});
|
|
940
|
+
dropdown.appendChild(div);
|
|
941
|
+
});
|
|
942
|
+
dropdown.hidden = false;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
input.addEventListener('input', () => showFor(input.value));
|
|
946
|
+
input.addEventListener('focus', () => {
|
|
947
|
+
if (input.value.trim()) showFor(input.value);
|
|
948
|
+
});
|
|
949
|
+
input.addEventListener('blur', () => hide());
|
|
950
|
+
input.addEventListener('keydown', (e) => {
|
|
951
|
+
if (e.key === 'ArrowDown' && items.length) {
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
activeIndex = (activeIndex + 1) % items.length;
|
|
954
|
+
renderActive();
|
|
955
|
+
} else if (e.key === 'ArrowUp' && items.length) {
|
|
956
|
+
e.preventDefault();
|
|
957
|
+
activeIndex = (activeIndex - 1 + items.length) % items.length;
|
|
958
|
+
renderActive();
|
|
959
|
+
} else if (e.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
|
|
960
|
+
e.preventDefault();
|
|
961
|
+
select(items[activeIndex]);
|
|
962
|
+
} else if (e.key === 'Escape') {
|
|
963
|
+
hide();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
832
968
|
// One name+lat+lon(+optional "use my position") row, shared by start
|
|
833
|
-
// line, finish line, and mark entry.
|
|
969
|
+
// line, finish line, and mark entry. The name field autocompletes against
|
|
970
|
+
// existing SignalK waypoints (e.g. ones already placed on a chart
|
|
971
|
+
// plotter) — picking one fills in lat/lon (and the name) from it, so a
|
|
972
|
+
// start/mark/finish position doesn't have to be typed by hand if a
|
|
973
|
+
// waypoint for it already exists.
|
|
834
974
|
function buildPointRow(point) {
|
|
835
975
|
const row = document.createElement('div');
|
|
836
976
|
row.className = 'course-point-row';
|
|
837
977
|
|
|
978
|
+
const nameWrap = document.createElement('div');
|
|
979
|
+
nameWrap.className = 'autocomplete course-name-autocomplete';
|
|
980
|
+
|
|
838
981
|
const nameInput = document.createElement('input');
|
|
839
982
|
nameInput.type = 'text';
|
|
840
983
|
nameInput.className = 'course-name-input';
|
|
841
984
|
nameInput.placeholder = 'Name (optional)';
|
|
985
|
+
nameInput.autocomplete = 'off';
|
|
842
986
|
nameInput.value = (point && point.name) || '';
|
|
843
987
|
|
|
988
|
+
const nameSuggestions = document.createElement('div');
|
|
989
|
+
nameSuggestions.className = 'suggestions';
|
|
990
|
+
nameSuggestions.hidden = true;
|
|
991
|
+
nameWrap.append(nameInput, nameSuggestions);
|
|
992
|
+
|
|
844
993
|
const latInput = document.createElement('input');
|
|
845
994
|
latInput.type = 'number';
|
|
846
995
|
latInput.step = 'any';
|
|
@@ -869,7 +1018,18 @@
|
|
|
869
1018
|
}
|
|
870
1019
|
});
|
|
871
1020
|
|
|
872
|
-
|
|
1021
|
+
attachAutocomplete(
|
|
1022
|
+
nameInput,
|
|
1023
|
+
nameSuggestions,
|
|
1024
|
+
() => waypoints,
|
|
1025
|
+
(wp) => {
|
|
1026
|
+
nameInput.value = wp.name;
|
|
1027
|
+
latInput.value = wp.lat;
|
|
1028
|
+
lonInput.value = wp.lon;
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
row.append(nameWrap, latInput, lonInput, useHereBtn);
|
|
873
1033
|
return { row, nameInput, latInput, lonInput };
|
|
874
1034
|
}
|
|
875
1035
|
|
|
@@ -1220,15 +1380,50 @@
|
|
|
1220
1380
|
});
|
|
1221
1381
|
const cutoff = replayLive ? Infinity : replayTime;
|
|
1222
1382
|
|
|
1383
|
+
// A boat's track is a series of discrete observations (an AIS sample
|
|
1384
|
+
// roughly every 15s, or a single point at a manually-recorded mark
|
|
1385
|
+
// rounding) — the path between them is drawn as a straight line, but
|
|
1386
|
+
// that's an assumption, not a fact. Two things make that assumption
|
|
1387
|
+
// visible instead of silently implied: a small dot at every actual
|
|
1388
|
+
// observation, and — for the moving "current position" marker — linear
|
|
1389
|
+
// interpolation between the two observations straddling the replay
|
|
1390
|
+
// time, drawn as a hollow ring instead of a solid dot so it reads as
|
|
1391
|
+
// estimated, not observed. Only interpolated across a reasonably tight
|
|
1392
|
+
// gap; beyond that (AIS dropped out, or a mark rounding recorded far
|
|
1393
|
+
// from any real fix) a straight line would just fabricate a plausible-
|
|
1394
|
+
// looking but likely wrong path, so it falls back to the last real
|
|
1395
|
+
// observation instead, same as before this existed.
|
|
1396
|
+
const MAX_INTERP_GAP_MS = 5 * 60 * 1000;
|
|
1397
|
+
|
|
1223
1398
|
boatsWithTrack.forEach((b, idx) => {
|
|
1224
1399
|
const color = CHART_PALETTE[idx % CHART_PALETTE.length];
|
|
1225
1400
|
const pts = b.track.filter((pt) => pt.t <= cutoff);
|
|
1226
1401
|
if (!pts.length) return;
|
|
1227
1402
|
const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
|
|
1228
1403
|
parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85" />`);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1404
|
+
pts.forEach((pt) => {
|
|
1405
|
+
const p = proj(pt);
|
|
1406
|
+
parts.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2" fill="${color}" opacity="0.7" />`);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
const prev = pts[pts.length - 1];
|
|
1410
|
+
let current = prev;
|
|
1411
|
+
let interpolated = false;
|
|
1412
|
+
if (!replayLive && prev.t < cutoff) {
|
|
1413
|
+
const next = b.track.find((pt) => pt.t > cutoff);
|
|
1414
|
+
if (next && next.t - prev.t <= MAX_INTERP_GAP_MS) {
|
|
1415
|
+
const frac = (cutoff - prev.t) / (next.t - prev.t);
|
|
1416
|
+
current = { lat: prev.lat + (next.lat - prev.lat) * frac, lon: prev.lon + (next.lon - prev.lon) * frac };
|
|
1417
|
+
interpolated = true;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const cur = proj(current);
|
|
1421
|
+
if (interpolated) {
|
|
1422
|
+
parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" fill-opacity="0.35" stroke="${color}" stroke-width="1.5" stroke-dasharray="2,1.5" />`);
|
|
1423
|
+
} else {
|
|
1424
|
+
parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" />`);
|
|
1425
|
+
}
|
|
1426
|
+
parts.push(`<text x="${(cur.x + 7).toFixed(1)}" y="${(cur.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
|
|
1232
1427
|
});
|
|
1233
1428
|
|
|
1234
1429
|
courseChart.innerHTML = parts.join('');
|
|
@@ -1278,6 +1473,8 @@
|
|
|
1278
1473
|
finishTime: b.finishTime,
|
|
1279
1474
|
dnf: !!b.dnf,
|
|
1280
1475
|
dnfPosition: b.dnfPosition || null,
|
|
1476
|
+
markTimes: b.markTimes || {},
|
|
1477
|
+
roundedMarksCount: b.roundedMarksCount || 0,
|
|
1281
1478
|
elapsedMs,
|
|
1282
1479
|
correctedMs,
|
|
1283
1480
|
estimate,
|
|
@@ -1288,6 +1485,13 @@
|
|
|
1288
1485
|
if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
|
|
1289
1486
|
if (a.rankMs == null) return 1;
|
|
1290
1487
|
if (b.rankMs == null) return -1;
|
|
1488
|
+
// Mirrors the server's rankedBoatList: with no AIS-based ETA to
|
|
1489
|
+
// fall back on, a boat recorded further around the course (by
|
|
1490
|
+
// either automatic AIS detection or a manually recorded rounding)
|
|
1491
|
+
// ranks ahead regardless of corrected time so far.
|
|
1492
|
+
if (!a.finishTime && !b.finishTime && !a.estimate && !b.estimate && a.roundedMarksCount !== b.roundedMarksCount) {
|
|
1493
|
+
return b.roundedMarksCount - a.roundedMarksCount;
|
|
1494
|
+
}
|
|
1291
1495
|
return a.rankMs - b.rankMs;
|
|
1292
1496
|
});
|
|
1293
1497
|
}
|
|
@@ -1465,6 +1669,15 @@
|
|
|
1465
1669
|
|
|
1466
1670
|
const tdElapsed = document.createElement('td');
|
|
1467
1671
|
const tdCorrected = document.createElement('td');
|
|
1672
|
+
// One small pill button per course mark, in rounding order — click an
|
|
1673
|
+
// unrounded one to record "rounded now", click a rounded one to clear
|
|
1674
|
+
// it. Rebuilt only when the course's marks actually change (see
|
|
1675
|
+
// render()), not every tick. Independent of the automatic AIS
|
|
1676
|
+
// track-based rounding detection; either one counts (see the server's
|
|
1677
|
+
// countRoundedMarks) — this is the manual alternative, for a boat with
|
|
1678
|
+
// no MMSI/AIS at all or to correct a rounding the detection missed.
|
|
1679
|
+
const tdMarks = document.createElement('td');
|
|
1680
|
+
tdMarks.className = 'marks-cell';
|
|
1468
1681
|
const tdEstFinish = document.createElement('td');
|
|
1469
1682
|
tdEstFinish.className = 'est-finish';
|
|
1470
1683
|
const tdVsSelf = document.createElement('td');
|
|
@@ -1539,12 +1752,15 @@
|
|
|
1539
1752
|
const tdRemove = document.createElement('td');
|
|
1540
1753
|
tdRemove.appendChild(removeBtn);
|
|
1541
1754
|
|
|
1542
|
-
tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
|
|
1755
|
+
tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdMarks, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
|
|
1543
1756
|
|
|
1544
1757
|
return {
|
|
1545
1758
|
tr,
|
|
1546
1759
|
selfBtn,
|
|
1547
1760
|
nameSpan,
|
|
1761
|
+
tdMarks,
|
|
1762
|
+
marksSignature: undefined,
|
|
1763
|
+
marksPills: [],
|
|
1548
1764
|
tdEstFinish,
|
|
1549
1765
|
tdVsSelf,
|
|
1550
1766
|
sailNumberInput,
|
|
@@ -1744,6 +1960,33 @@
|
|
|
1744
1960
|
row.tdElapsed.textContent = fmtDuration(b.elapsedMs);
|
|
1745
1961
|
row.tdCorrected.textContent = fmtDuration(b.correctedMs);
|
|
1746
1962
|
|
|
1963
|
+
const courseMarks = (raceState.course && raceState.course.marks) || [];
|
|
1964
|
+
const marksSig = courseMarks.map((m) => m.id).join(',');
|
|
1965
|
+
if (row.marksSignature !== marksSig) {
|
|
1966
|
+
row.tdMarks.innerHTML = '';
|
|
1967
|
+
row.marksPills = courseMarks.map((m, i) => {
|
|
1968
|
+
const pill = document.createElement('button');
|
|
1969
|
+
pill.type = 'button';
|
|
1970
|
+
pill.className = 'mark-pill';
|
|
1971
|
+
pill.textContent = String(i + 1);
|
|
1972
|
+
pill.addEventListener('click', () => {
|
|
1973
|
+
const boat = raceState.boats[b.boatId];
|
|
1974
|
+
const already = boat && boat.markTimes && boat.markTimes[m.id] != null;
|
|
1975
|
+
setMarkTime(b.boatId, m.id, already ? null : Date.now());
|
|
1976
|
+
});
|
|
1977
|
+
row.tdMarks.appendChild(pill);
|
|
1978
|
+
return pill;
|
|
1979
|
+
});
|
|
1980
|
+
row.marksSignature = marksSig;
|
|
1981
|
+
}
|
|
1982
|
+
courseMarks.forEach((m, i) => {
|
|
1983
|
+
const pill = row.marksPills[i];
|
|
1984
|
+
const t = b.markTimes && b.markTimes[m.id];
|
|
1985
|
+
pill.classList.toggle('rounded', t != null);
|
|
1986
|
+
const label = m.name || `Mark ${i + 1}`;
|
|
1987
|
+
pill.title = t != null ? `${label} — rounded ${new Date(t).toLocaleTimeString()} (click to clear)` : `${label} — click to mark rounded now`;
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1747
1990
|
if (b.dnf) {
|
|
1748
1991
|
row.tdEstFinish.textContent = 'DNF';
|
|
1749
1992
|
} else if (b.finishTime) {
|
|
@@ -1891,13 +2134,67 @@
|
|
|
1891
2134
|
renderMarkRows();
|
|
1892
2135
|
});
|
|
1893
2136
|
saveCourseBtn.addEventListener('click', saveCourse);
|
|
2137
|
+
|
|
2138
|
+
// Runs the replay forward at replaySpeed simulated seconds per real
|
|
2139
|
+
// second, on its own timer (independent of the general 1s render tick)
|
|
2140
|
+
// so playback still looks reasonably smooth at low speeds. Stops itself
|
|
2141
|
+
// once it reaches the latest recorded position — there's nothing to play
|
|
2142
|
+
// into beyond that until more track is recorded.
|
|
2143
|
+
function stopReplayPlayback() {
|
|
2144
|
+
replayPlaying = false;
|
|
2145
|
+
replayPlayBtn.textContent = '▶ Play';
|
|
2146
|
+
if (replayPlayTimer) {
|
|
2147
|
+
clearInterval(replayPlayTimer);
|
|
2148
|
+
replayPlayTimer = null;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
function replayTick() {
|
|
2152
|
+
const now = Date.now();
|
|
2153
|
+
const elapsedMs = now - replayPlayLastTick;
|
|
2154
|
+
replayPlayLastTick = now;
|
|
2155
|
+
const maxT = Number(replaySlider.max);
|
|
2156
|
+
if (!isFinite(maxT)) {
|
|
2157
|
+
stopReplayPlayback();
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
replayTime = Math.min(replayTime + elapsedMs * replaySpeed, maxT);
|
|
2161
|
+
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
2162
|
+
if (replayTime >= maxT) stopReplayPlayback();
|
|
2163
|
+
renderChart();
|
|
2164
|
+
}
|
|
2165
|
+
function startReplayPlayback() {
|
|
2166
|
+
if (replayPlaying) return;
|
|
2167
|
+
// Nothing to play forward into from Live — start over from the
|
|
2168
|
+
// earliest recorded position instead.
|
|
2169
|
+
if (replayLive) {
|
|
2170
|
+
const minT = Number(replaySlider.min);
|
|
2171
|
+
if (isFinite(minT)) replayTime = minT;
|
|
2172
|
+
}
|
|
2173
|
+
replayLive = false;
|
|
2174
|
+
replayPlaying = true;
|
|
2175
|
+
replayPlayBtn.textContent = '⏸ Pause';
|
|
2176
|
+
replayPlayLastTick = Date.now();
|
|
2177
|
+
replayPlayTimer = setInterval(replayTick, 200);
|
|
2178
|
+
renderChart();
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1894
2181
|
replaySlider.addEventListener('input', () => {
|
|
2182
|
+
stopReplayPlayback();
|
|
1895
2183
|
replayLive = false;
|
|
1896
2184
|
replayTime = Number(replaySlider.value);
|
|
1897
2185
|
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
1898
2186
|
renderChart();
|
|
1899
2187
|
});
|
|
2188
|
+
replayPlayBtn.addEventListener('click', () => {
|
|
2189
|
+
if (replayPlaying) stopReplayPlayback();
|
|
2190
|
+
else startReplayPlayback();
|
|
2191
|
+
});
|
|
2192
|
+
replaySpeedSlider.addEventListener('input', () => {
|
|
2193
|
+
replaySpeed = Number(replaySpeedSlider.value);
|
|
2194
|
+
replaySpeedLabel.textContent = replaySpeed + 'x';
|
|
2195
|
+
});
|
|
1900
2196
|
replayLiveBtn.addEventListener('click', () => {
|
|
2197
|
+
stopReplayPlayback();
|
|
1901
2198
|
replayLive = true;
|
|
1902
2199
|
renderChart();
|
|
1903
2200
|
});
|
|
@@ -1905,7 +2202,7 @@
|
|
|
1905
2202
|
async function init() {
|
|
1906
2203
|
await loadVetEnabled();
|
|
1907
2204
|
await loadRaceImportEnabled();
|
|
1908
|
-
await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
|
|
2205
|
+
await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadWaypoints(), loadRacesList()]);
|
|
1909
2206
|
await loadRaceState();
|
|
1910
2207
|
render();
|
|
1911
2208
|
setInterval(render, 1000);
|
package/public/index.html
CHANGED
|
@@ -86,6 +86,9 @@
|
|
|
86
86
|
<div id="replayControls" class="replay-controls" hidden>
|
|
87
87
|
<input type="range" id="replaySlider" min="0" max="100" value="100" />
|
|
88
88
|
<span id="replayTimeLabel">Live</span>
|
|
89
|
+
<button id="replayPlayBtn" type="button" class="secondary">▶ Play</button>
|
|
90
|
+
<input type="range" id="replaySpeedSlider" class="replay-speed-slider" min="1" max="60" value="10" title="Playback speed" />
|
|
91
|
+
<span id="replaySpeedLabel" class="replay-speed-label">10x</span>
|
|
89
92
|
<button id="replayLiveBtn" type="button" class="secondary">Live</button>
|
|
90
93
|
</div>
|
|
91
94
|
</div>
|
|
@@ -127,6 +130,7 @@
|
|
|
127
130
|
<th>Start time</th>
|
|
128
131
|
<th>Elapsed</th>
|
|
129
132
|
<th>Corrected</th>
|
|
133
|
+
<th>Marks</th>
|
|
130
134
|
<th>Est. finish</th>
|
|
131
135
|
<th>vs Self</th>
|
|
132
136
|
<th>Finish time</th>
|
package/public/style.css
CHANGED
|
@@ -257,8 +257,13 @@ main {
|
|
|
257
257
|
align-items: center;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
.course-point-row .course-name-
|
|
260
|
+
.course-point-row .course-name-autocomplete {
|
|
261
|
+
position: relative;
|
|
261
262
|
width: 7rem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.course-point-row .course-name-input {
|
|
266
|
+
width: 100%;
|
|
262
267
|
padding: 0.3rem 0.4rem;
|
|
263
268
|
background: var(--panel);
|
|
264
269
|
color: var(--text);
|
|
@@ -364,6 +369,7 @@ main {
|
|
|
364
369
|
.replay-controls {
|
|
365
370
|
margin-top: 0.5rem;
|
|
366
371
|
display: flex;
|
|
372
|
+
flex-wrap: wrap;
|
|
367
373
|
align-items: center;
|
|
368
374
|
gap: 0.6rem;
|
|
369
375
|
max-width: 800px;
|
|
@@ -373,6 +379,11 @@ main {
|
|
|
373
379
|
flex: 1;
|
|
374
380
|
}
|
|
375
381
|
|
|
382
|
+
.replay-controls input[type='range'].replay-speed-slider {
|
|
383
|
+
flex: none;
|
|
384
|
+
width: 5rem;
|
|
385
|
+
}
|
|
386
|
+
|
|
376
387
|
.replay-controls span {
|
|
377
388
|
font-size: 0.8rem;
|
|
378
389
|
color: var(--muted);
|
|
@@ -380,6 +391,37 @@ main {
|
|
|
380
391
|
min-width: 5rem;
|
|
381
392
|
}
|
|
382
393
|
|
|
394
|
+
.replay-controls span.replay-speed-label {
|
|
395
|
+
min-width: 2.2rem;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.marks-cell {
|
|
399
|
+
display: flex;
|
|
400
|
+
flex-wrap: wrap;
|
|
401
|
+
gap: 0.25rem;
|
|
402
|
+
min-width: 6rem;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.mark-pill {
|
|
406
|
+
width: 1.6rem;
|
|
407
|
+
height: 1.6rem;
|
|
408
|
+
padding: 0;
|
|
409
|
+
border-radius: 50%;
|
|
410
|
+
border: 1px solid var(--border);
|
|
411
|
+
background: transparent;
|
|
412
|
+
color: var(--muted);
|
|
413
|
+
font-size: 0.7rem;
|
|
414
|
+
font-weight: 700;
|
|
415
|
+
cursor: pointer;
|
|
416
|
+
line-height: 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.mark-pill.rounded {
|
|
420
|
+
background: var(--accent);
|
|
421
|
+
border-color: var(--accent);
|
|
422
|
+
color: #04202e;
|
|
423
|
+
}
|
|
424
|
+
|
|
383
425
|
.est-finish {
|
|
384
426
|
font-size: 0.85rem;
|
|
385
427
|
color: var(--muted);
|