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 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 (or picked up automatically
53
- from a live AIS/self vessel with a matching name), it's remembered in a small
54
- cross-race registry: add a boat with the same name in a later race and its MMSI
55
- fills in on its own.
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). The webapp draws them on a built-in chart, overlaid with each AIS-tracked
79
- boat's recorded track for the current race drag the **replay** slider to step
80
- back through it, or leave it on **Live**. The course is also published as SignalK
78
+ spot, or type a name to autocomplete against existing SignalK waypoints e.g. ones
79
+ already placed from a chart plotterand 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 — that part only does anything if your server has a
83
- resources provider installed; it's a no-op otherwise, never a failure.
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 ~0.1nm of each mark
89
- in order) rather than cutting straight to the finish accuracy still depends on
90
- that detection actually catching each rounding.
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 memory: once a
1339
- // boat's MMSI or (for boats outside VET) TCF is entered anywhere, it's
1340
- // applied automatically next time that name is used in any race.
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 other. tcf is only ever
1364
- // written here for boats outside the VET register — see the callers.
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. ~0.1nm (~185m) loose enough to tolerate AIS
1459
- // position jitter and the 15s sampling gap (a boat doing 7kn covers about
1460
- // 0.03nm between samples) without needing an exact pass.
1461
- const MARK_ROUNDING_RADIUS_NM = 0.1;
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 countRoundedMarks(race, boat) {
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]) <= MARK_ROUNDING_RADIUS_NM) {
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 boat without mutating the
1529
- // stored race — it's derived from live data, never persisted.
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
- b.estimate = estimateFinish(race, race.boats[b.id]);
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 || lookupBoatRegistry(name);
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
- const remembered = getRegistryEntry(name);
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: null,
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.1",
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
- row.append(nameInput, latInput, lonInput, useHereBtn);
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
- const last = proj(pts[pts.length - 1]);
1230
- parts.push(`<circle cx="${last.x}" cy="${last.y}" r="4" fill="${color}" />`);
1231
- parts.push(`<text x="${(last.x + 7).toFixed(1)}" y="${(last.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
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-input {
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);