signalk-race-control 0.4.2 → 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
@@ -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,6 +1330,12 @@ 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
  };
@@ -1453,29 +1460,58 @@ module.exports = function (app) {
1453
1460
  }
1454
1461
 
1455
1462
  // A mark counts as "rounded" once the boat's recorded track came within
1456
- // this radius of it. ~0.1nm (~185m) loose enough to tolerate AIS
1457
- // position jitter and the 15s sampling gap (a boat doing 7kn covers about
1458
- // 0.03nm between samples) without needing an exact pass.
1459
- 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
+ }
1460
1472
 
1461
1473
  // Scans a boat's recorded track chronologically, advancing to the next
1462
1474
  // mark each time the track comes within rounding radius of the current
1463
1475
  // one — so it naturally requires marks to be rounded in course order.
1464
1476
  // Automatic (no manual "boat X rounded mark Y" input) by design, at the
1465
1477
  // cost of missing a rounding if a boat cuts far outside the radius.
1466
- function countRoundedMarks(race, boat) {
1478
+ function countAutoRoundedMarks(race, boat) {
1467
1479
  const marks = race.course.marks;
1468
1480
  if (!marks.length || !boat.track || !boat.track.length) return 0;
1481
+ const radiusNm = markRoundingRadiusNm();
1469
1482
  let markIdx = 0;
1470
1483
  for (const pt of boat.track) {
1471
1484
  if (markIdx >= marks.length) break;
1472
- if (distanceNm(pt, marks[markIdx]) <= MARK_ROUNDING_RADIUS_NM) {
1485
+ if (distanceNm(pt, marks[markIdx]) <= radiusNm) {
1473
1486
  markIdx++;
1474
1487
  }
1475
1488
  }
1476
1489
  return markIdx;
1477
1490
  }
1478
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
+
1479
1515
  // Distance from the boat's current position, around each remaining mark
1480
1516
  // in order, to the finish line — not just a straight line to the finish.
1481
1517
  // Returns null if there's nothing left to route to (no remaining marks and
@@ -1523,12 +1559,16 @@ module.exports = function (app) {
1523
1559
  };
1524
1560
  }
1525
1561
 
1526
- // Attaches a live `estimate` to each unfinished boat without mutating the
1527
- // 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).
1528
1566
  function raceWithEstimates(race) {
1529
1567
  const out = JSON.parse(JSON.stringify(race));
1530
1568
  Object.values(out.boats).forEach((b) => {
1531
- 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);
1532
1572
  });
1533
1573
  return out;
1534
1574
  }
@@ -1546,13 +1586,24 @@ module.exports = function (app) {
1546
1586
  const tcf = boat.tcf != null ? boat.tcf : 1.0;
1547
1587
  const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
1548
1588
  const estimate = estimateFinish(race, boat);
1589
+ const roundedMarksCount = countRoundedMarks(race, boat);
1549
1590
  const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1550
- return { boat, elapsedMs, correctedMs, estimate, rankMs };
1591
+ return { boat, elapsedMs, correctedMs, estimate, roundedMarksCount, rankMs };
1551
1592
  })
1552
1593
  .sort((a, b) => {
1553
1594
  if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
1554
1595
  if (a.rankMs == null) return 1;
1555
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
+ }
1556
1607
  return a.rankMs - b.rankMs;
1557
1608
  });
1558
1609
  }
@@ -2188,7 +2239,8 @@ module.exports = function (app) {
2188
2239
  startTime: null,
2189
2240
  track: [],
2190
2241
  dnf: false,
2191
- dnfPosition: null
2242
+ dnfPosition: null,
2243
+ markTimes: {}
2192
2244
  };
2193
2245
  race.boats[boat.id] = boat;
2194
2246
  saveState();
@@ -2274,6 +2326,47 @@ module.exports = function (app) {
2274
2326
  res.json(boat);
2275
2327
  });
2276
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
+
2277
2370
  router.put('/races/:id/boats/:boatId/mmsi', (req, res) => {
2278
2371
  const race = getRace(req.params.id);
2279
2372
  if (!race) return res.status(404).json({ error: 'No such race' });
@@ -2309,6 +2402,32 @@ module.exports = function (app) {
2309
2402
  res.json({ enabled: isVetEnabled() });
2310
2403
  });
2311
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
+
2312
2431
  router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
2313
2432
  const race = getRace(req.params.id);
2314
2433
  if (!race) return res.status(404).json({ error: 'No such race' });
@@ -2428,7 +2547,8 @@ module.exports = function (app) {
2428
2547
  startTime: null,
2429
2548
  track: [],
2430
2549
  dnf: false,
2431
- dnfPosition: null
2550
+ dnfPosition: null,
2551
+ markTimes: {}
2432
2552
  };
2433
2553
  race.boats[boat.id] = boat;
2434
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.2",
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);