signalk-race-control 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,21 +73,56 @@ and a live projected finishing order — during and after the race.
73
73
  - **Editable finish times** — click **Now** to record a finish as it happens, type a
74
74
  specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
75
75
  **Clear** to undo. Handles races that cross midnight.
76
+ - **Start timer** — once a start line is set and a start is scheduled, an expandable
77
+ **Start timer** panel shows a countdown to the gun alongside three live numbers for
78
+ this vessel specifically (from its own SignalK GPS/speed, not any other tracked
79
+ boat): distance to the line, ETA to reach it at current speed, and **time to burn** —
80
+ the countdown minus that ETA. Positive means time to spare (you'll arrive early, so
81
+ slow down or take a longer approach); negative means you're behind schedule to make
82
+ the line before the gun. Disappears once the race actually starts, since the
83
+ pre-start approach is moot by then.
76
84
  - **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
77
85
  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
86
+ spot, or type a name to autocomplete against existing SignalK waypoints e.g. ones
87
+ already placed from a chart plotterand pick one to fill in its position). The
88
+ webapp draws them on a built-in chart, overlaid with each AIS-tracked boat's
89
+ recorded track for the current race — drag the **replay** slider to step back
90
+ through it, or leave it on **Live**. Click **Play** to have it step through on its own
91
+ instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
92
+ from the earliest recorded position when played from Live (there's nothing to play
93
+ forward into from there), stops on its own once it catches up to the latest one, and
94
+ dragging the main slider or clicking **Live** stops it early. Every actual recorded
95
+ position (an AIS sample,
96
+ or a manually-recorded mark rounding) shows as a small dot along the track, so it's
97
+ clear which points are real; wherever the slider sits between two of them, the boat's
98
+ position is linearly interpolated and drawn as a hollow ring instead of a solid dot,
99
+ so an in-between position always reads as estimated rather than observed. It falls
100
+ back to just the last real position (no ring) instead of interpolating across a gap
101
+ longer than 5 minutes — AIS dropping out, or a mark rounding recorded far from any
102
+ real fix — since a straight line across a gap that long would be a guess, not an
103
+ estimate. The course is also published as SignalK
81
104
  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.
105
+ standard resources API — both directions (reading existing waypoints for the
106
+ autocomplete, and publishing the saved course) only do anything if your server has a
107
+ resources provider installed; they're a no-op otherwise, never a failure.
84
108
  - **Estimated finish time & live rank** — while a boat is still racing, if it has a
85
109
  live AIS position and speed and the race has a finish line, the plugin projects a
86
110
  finish time and corrected time from its remaining distance and speed, and ranks it
87
111
  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.
112
+ (detected automatically from its recorded track passing within a configurable radius
113
+ `markRoundingRadiusM`, 100m by default of each mark in order) rather than
114
+ cutting straight to the finish — accuracy still depends on that detection actually
115
+ catching each rounding. A rounding can also be recorded by hand: each boat's row has
116
+ a numbered pill per mark in the **Marks** column — click one to mark it rounded right
117
+ now, click again to clear it — for a boat with no MMSI/AIS at all, or to correct one
118
+ the automatic detection missed. Either source counts toward the same rounding, and
119
+ for a boat still racing with no AIS-based estimate to rank by, marks rounded (by
120
+ either method) takes priority over raw elapsed time, so a boat further round the
121
+ course still ranks ahead even without live tracking. A manual rounding also records a
122
+ position for the replay chart — the boat's live position if it has AIS, otherwise the
123
+ mark's own position (a reasonable stand-in, since rounding a mark means being at it)
124
+ — so even a boat with no AIS at all shows up on the chart at each mark it's recorded
125
+ rounding.
91
126
  - **Stop / call off the race** — freezes elapsed/corrected time for everyone without
92
127
  touching boats, finish times, or the course (unlike Reset, which clears the race
93
128
  back to not-started), and marks every boat that hadn't finished as **DNF**,
@@ -200,3 +235,7 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
200
235
  setting, not a per-race or webapp-side option — change it under
201
236
  **Server → Plugin Config → Race Control** and restart the plugin (the server does
202
237
  this automatically on save) for the webapp to pick it up.
238
+ - The mark-rounding radius used for the estimated finish time and remaining-distance
239
+ calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
240
+ loosen it for a fleet with noisier AIS tracks, or tighten it if marks sit close
241
+ 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.5.0",
4
4
  "description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
5
5
  "main": "index.js",
6
+ "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,7 +81,18 @@
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');
88
+ const startTimerSection = document.getElementById('startTimerSection');
89
+ const startTimerToggleBtn = document.getElementById('startTimerToggleBtn');
90
+ const startTimerBody = document.getElementById('startTimerBody');
91
+ const startCountdownValue = document.getElementById('startCountdownValue');
92
+ const startDtlValue = document.getElementById('startDtlValue');
93
+ const startEtaValue = document.getElementById('startEtaValue');
94
+ const startBurnValue = document.getElementById('startBurnValue');
95
+ const startTimerNote = document.getElementById('startTimerNote');
80
96
  const raceImportSection = document.getElementById('raceImportSection');
81
97
  const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
82
98
  const raceImportBody = document.getElementById('raceImportBody');
@@ -363,6 +379,19 @@
363
379
  }
364
380
  }
365
381
 
382
+ // Best-effort, like the underlying resourcesApi calls it wraps — an empty
383
+ // list (no resources provider registered, or the request fails) just
384
+ // means the course editor's "Pick a waypoint" dropdowns have nothing to
385
+ // offer, not a failure worth surfacing.
386
+ async function loadWaypoints() {
387
+ try {
388
+ const data = await fetchJSON(`${API}/waypoints`);
389
+ waypoints = data.waypoints || [];
390
+ } catch (e) {
391
+ waypoints = [];
392
+ }
393
+ }
394
+
366
395
  async function loadHandicapRegister(force) {
367
396
  if (!vetEnabled) return;
368
397
  try {
@@ -408,6 +437,11 @@
408
437
  if (id !== lastCourseFormRaceId) {
409
438
  loadCourseFormFromRace();
410
439
  lastCourseFormRaceId = id;
440
+ // A running replay is specific to whichever race's track it was
441
+ // playing through — switching races (or to none) leaves it with
442
+ // nothing sensible to keep advancing into.
443
+ stopReplayPlayback();
444
+ replayLive = true;
411
445
  }
412
446
  }
413
447
 
@@ -757,6 +791,28 @@
757
791
  }
758
792
  }
759
793
 
794
+ // Records (or, with ts: null, clears) a boat's own manually-entered
795
+ // rounding time for one specific mark — independent of the automatic
796
+ // AIS-track-based detection, for a boat with no MMSI/AIS at all or to
797
+ // correct a rounding the automatic detection missed.
798
+ async function setMarkTime(boatId, markId, ts) {
799
+ if (!activeRaceId) return;
800
+ try {
801
+ const boat = await fetchJSON(
802
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/markTimes/${encodeURIComponent(markId)}`,
803
+ {
804
+ method: 'PUT',
805
+ headers: { 'Content-Type': 'application/json' },
806
+ body: JSON.stringify({ time: ts })
807
+ }
808
+ );
809
+ raceState.boats[boatId] = boat;
810
+ render();
811
+ } catch (e) {
812
+ setStatus(e.message, true);
813
+ }
814
+ }
815
+
760
816
  async function setMmsi(boatId, mmsi) {
761
817
  if (!activeRaceId) return;
762
818
  try {
@@ -829,18 +885,236 @@
829
885
  return null;
830
886
  }
831
887
 
888
+ // ---- Start timer ------------------------------------------------------
889
+ // Countdown to the scheduled start + a live "distance/ETA to the start
890
+ // line, time to burn" instrument for this vessel — the same thing a
891
+ // start-line transit timer app shows, computed from this vessel's own
892
+ // live position/speed (not any other boat's).
893
+
894
+ let selfNav = null; // {lat, lon, sogMs} | null, refreshed on its own poll — see below
895
+
896
+ async function fetchSelfNav() {
897
+ try {
898
+ const [posLeaf, sogLeaf] = await Promise.all([
899
+ fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
900
+ fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
901
+ ]);
902
+ const v = posLeaf && posLeaf.value;
903
+ if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') return null;
904
+ const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
905
+ return { lat: v.latitude, lon: v.longitude, sogMs };
906
+ } catch (e) {
907
+ return null;
908
+ }
909
+ }
910
+
911
+ const MS_TO_KNOTS = 1.9438444924574;
912
+ function toRad(deg) {
913
+ return (deg * Math.PI) / 180;
914
+ }
915
+
916
+ // Perpendicular distance from a point to the start-line segment (or to
917
+ // its nearest end, if the point doesn't fall between the two ends) — a
918
+ // flat-earth approximation local to the line, which is fine at the scale
919
+ // of a start line and a boat's approach to it (same simplifying
920
+ // assumption the course chart's own projection already makes).
921
+ function distanceToSegmentNm(p, a, b) {
922
+ const meanLat = (a.lat + b.lat + p.lat) / 3;
923
+ const cosLat = Math.cos(toRad(meanLat)) || 1;
924
+ const NM_PER_DEG_LAT = 60;
925
+ const toXY = (pt) => ({ x: pt.lon * cosLat * NM_PER_DEG_LAT, y: pt.lat * NM_PER_DEG_LAT });
926
+ const P = toXY(p);
927
+ const A = toXY(a);
928
+ const B = toXY(b);
929
+ const abx = B.x - A.x;
930
+ const aby = B.y - A.y;
931
+ const lenSq = abx * abx + aby * aby;
932
+ let t = lenSq > 0 ? ((P.x - A.x) * abx + (P.y - A.y) * aby) / lenSq : 0;
933
+ t = Math.max(0, Math.min(1, t));
934
+ const cx = A.x + abx * t;
935
+ const cy = A.y + aby * t;
936
+ const dx = P.x - cx;
937
+ const dy = P.y - cy;
938
+ return Math.sqrt(dx * dx + dy * dy);
939
+ }
940
+
941
+ function fmtSignedDuration(ms) {
942
+ const sign = ms < 0 ? '-' : '+';
943
+ const s = Math.round(Math.abs(ms) / 1000);
944
+ const h = Math.floor(s / 3600);
945
+ const m = Math.floor((s % 3600) / 60);
946
+ const sec = s % 60;
947
+ return sign + (h ? `${h}:${String(m).padStart(2, '0')}` : m) + ':' + String(sec).padStart(2, '0');
948
+ }
949
+
950
+ // Shown whenever there's a start line to approach and the race hasn't
951
+ // actually started yet (pre-start is moot once it has) — refreshes every
952
+ // tick from the last-polled selfNav, independent of whether that poll
953
+ // itself just ran.
954
+ function renderStartTimer() {
955
+ const hasStartLine = !!(raceState && raceState.course && raceState.course.startLine);
956
+ startTimerSection.hidden = !raceState || !hasStartLine || !!raceState.startTime;
957
+ if (startTimerSection.hidden || startTimerBody.hidden) return;
958
+
959
+ const target = raceState.scheduledStart;
960
+ if (!target) {
961
+ startCountdownValue.textContent = '--:--:--';
962
+ startDtlValue.textContent = '--';
963
+ startEtaValue.textContent = '--:--:--';
964
+ startBurnValue.textContent = '--';
965
+ startBurnValue.className = 'start-timer-value';
966
+ startTimerNote.textContent = 'Set a scheduled start time above to see the countdown and burn time here.';
967
+ return;
968
+ }
969
+ const now = Date.now();
970
+ const toStartMs = target - now;
971
+ startCountdownValue.textContent = fmtDuration(Math.max(0, toStartMs));
972
+
973
+ if (!selfNav) {
974
+ startDtlValue.textContent = '--';
975
+ startEtaValue.textContent = '--:--:--';
976
+ startBurnValue.textContent = '--';
977
+ startBurnValue.className = 'start-timer-value';
978
+ startTimerNote.textContent = "Waiting for this vessel's own position from SignalK.";
979
+ return;
980
+ }
981
+ const [a, b] = raceState.course.startLine;
982
+ const dtlNm = distanceToSegmentNm(selfNav, a, b);
983
+ startDtlValue.textContent = `${dtlNm.toFixed(2)}nm`;
984
+
985
+ if (selfNav.sogMs == null || selfNav.sogMs < 0.25) {
986
+ startEtaValue.textContent = '--:--:--';
987
+ startBurnValue.textContent = '--';
988
+ startBurnValue.className = 'start-timer-value';
989
+ startTimerNote.textContent = 'Waiting for this vessel to be making way (SOG) to estimate ETA and burn time.';
990
+ return;
991
+ }
992
+ const sogKn = selfNav.sogMs * MS_TO_KNOTS;
993
+ const etaMs = (dtlNm / sogKn) * 3600 * 1000;
994
+ startEtaValue.textContent = new Date(now + etaMs).toLocaleTimeString();
995
+
996
+ const burnMs = toStartMs - etaMs;
997
+ startBurnValue.textContent = fmtSignedDuration(burnMs);
998
+ startBurnValue.className = 'start-timer-value ' + (burnMs >= 0 ? 'early' : 'late');
999
+ startTimerNote.textContent =
1000
+ burnMs >= 0
1001
+ ? "Positive: time to spare before the gun at this speed — you'll arrive at the line before it, so you have time to burn."
1002
+ : "Negative: you're behind schedule to reach the line at this speed before the gun.";
1003
+ }
1004
+
1005
+ // Generic substring-match autocomplete, wiring `input` to `dropdown` (a
1006
+ // hidden .suggestions element already sitting next to it in the DOM) —
1007
+ // same matching/keyboard-nav behavior as the boat-name autocomplete
1008
+ // above, but with its own closure-local item/selection state so several
1009
+ // instances (one per course point row) can coexist without stepping on
1010
+ // each other the way sharing the boat autocomplete's module-level state
1011
+ // would.
1012
+ function attachAutocomplete(input, dropdown, getPool, onSelect) {
1013
+ let items = [];
1014
+ let activeIndex = -1;
1015
+
1016
+ function hide() {
1017
+ dropdown.hidden = true;
1018
+ dropdown.innerHTML = '';
1019
+ items = [];
1020
+ activeIndex = -1;
1021
+ }
1022
+ function renderActive() {
1023
+ Array.from(dropdown.children).forEach((el, i) => el.classList.toggle('active', i === activeIndex));
1024
+ }
1025
+ function select(item) {
1026
+ onSelect(item);
1027
+ hide();
1028
+ }
1029
+ function showFor(query) {
1030
+ const q = query.trim().toLowerCase();
1031
+ if (!q) {
1032
+ hide();
1033
+ return;
1034
+ }
1035
+ const matches = getPool()
1036
+ .filter((it) => it.name.toLowerCase().includes(q))
1037
+ .slice(0, 20);
1038
+ items = matches;
1039
+ activeIndex = -1;
1040
+ if (!matches.length) {
1041
+ hide();
1042
+ return;
1043
+ }
1044
+ dropdown.innerHTML = '';
1045
+ matches.forEach((item) => {
1046
+ const div = document.createElement('div');
1047
+ div.className = 'suggestion-item';
1048
+ const idx = item.name.toLowerCase().indexOf(q);
1049
+ if (idx === -1) {
1050
+ div.textContent = item.name;
1051
+ } else {
1052
+ div.innerHTML =
1053
+ escapeHtml(item.name.slice(0, idx)) +
1054
+ '<mark>' +
1055
+ escapeHtml(item.name.slice(idx, idx + q.length)) +
1056
+ '</mark>' +
1057
+ escapeHtml(item.name.slice(idx + q.length));
1058
+ }
1059
+ // mousedown (not click) fires before the input's blur, same reason
1060
+ // as the boat-name suggestions above.
1061
+ div.addEventListener('mousedown', (e) => {
1062
+ e.preventDefault();
1063
+ select(item);
1064
+ });
1065
+ dropdown.appendChild(div);
1066
+ });
1067
+ dropdown.hidden = false;
1068
+ }
1069
+
1070
+ input.addEventListener('input', () => showFor(input.value));
1071
+ input.addEventListener('focus', () => {
1072
+ if (input.value.trim()) showFor(input.value);
1073
+ });
1074
+ input.addEventListener('blur', () => hide());
1075
+ input.addEventListener('keydown', (e) => {
1076
+ if (e.key === 'ArrowDown' && items.length) {
1077
+ e.preventDefault();
1078
+ activeIndex = (activeIndex + 1) % items.length;
1079
+ renderActive();
1080
+ } else if (e.key === 'ArrowUp' && items.length) {
1081
+ e.preventDefault();
1082
+ activeIndex = (activeIndex - 1 + items.length) % items.length;
1083
+ renderActive();
1084
+ } else if (e.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
1085
+ e.preventDefault();
1086
+ select(items[activeIndex]);
1087
+ } else if (e.key === 'Escape') {
1088
+ hide();
1089
+ }
1090
+ });
1091
+ }
1092
+
832
1093
  // One name+lat+lon(+optional "use my position") row, shared by start
833
- // line, finish line, and mark entry.
1094
+ // line, finish line, and mark entry. The name field autocompletes against
1095
+ // existing SignalK waypoints (e.g. ones already placed on a chart
1096
+ // plotter) — picking one fills in lat/lon (and the name) from it, so a
1097
+ // start/mark/finish position doesn't have to be typed by hand if a
1098
+ // waypoint for it already exists.
834
1099
  function buildPointRow(point) {
835
1100
  const row = document.createElement('div');
836
1101
  row.className = 'course-point-row';
837
1102
 
1103
+ const nameWrap = document.createElement('div');
1104
+ nameWrap.className = 'autocomplete course-name-autocomplete';
1105
+
838
1106
  const nameInput = document.createElement('input');
839
1107
  nameInput.type = 'text';
840
1108
  nameInput.className = 'course-name-input';
841
1109
  nameInput.placeholder = 'Name (optional)';
1110
+ nameInput.autocomplete = 'off';
842
1111
  nameInput.value = (point && point.name) || '';
843
1112
 
1113
+ const nameSuggestions = document.createElement('div');
1114
+ nameSuggestions.className = 'suggestions';
1115
+ nameSuggestions.hidden = true;
1116
+ nameWrap.append(nameInput, nameSuggestions);
1117
+
844
1118
  const latInput = document.createElement('input');
845
1119
  latInput.type = 'number';
846
1120
  latInput.step = 'any';
@@ -869,7 +1143,18 @@
869
1143
  }
870
1144
  });
871
1145
 
872
- row.append(nameInput, latInput, lonInput, useHereBtn);
1146
+ attachAutocomplete(
1147
+ nameInput,
1148
+ nameSuggestions,
1149
+ () => waypoints,
1150
+ (wp) => {
1151
+ nameInput.value = wp.name;
1152
+ latInput.value = wp.lat;
1153
+ lonInput.value = wp.lon;
1154
+ }
1155
+ );
1156
+
1157
+ row.append(nameWrap, latInput, lonInput, useHereBtn);
873
1158
  return { row, nameInput, latInput, lonInput };
874
1159
  }
875
1160
 
@@ -1220,15 +1505,50 @@
1220
1505
  });
1221
1506
  const cutoff = replayLive ? Infinity : replayTime;
1222
1507
 
1508
+ // A boat's track is a series of discrete observations (an AIS sample
1509
+ // roughly every 15s, or a single point at a manually-recorded mark
1510
+ // rounding) — the path between them is drawn as a straight line, but
1511
+ // that's an assumption, not a fact. Two things make that assumption
1512
+ // visible instead of silently implied: a small dot at every actual
1513
+ // observation, and — for the moving "current position" marker — linear
1514
+ // interpolation between the two observations straddling the replay
1515
+ // time, drawn as a hollow ring instead of a solid dot so it reads as
1516
+ // estimated, not observed. Only interpolated across a reasonably tight
1517
+ // gap; beyond that (AIS dropped out, or a mark rounding recorded far
1518
+ // from any real fix) a straight line would just fabricate a plausible-
1519
+ // looking but likely wrong path, so it falls back to the last real
1520
+ // observation instead, same as before this existed.
1521
+ const MAX_INTERP_GAP_MS = 5 * 60 * 1000;
1522
+
1223
1523
  boatsWithTrack.forEach((b, idx) => {
1224
1524
  const color = CHART_PALETTE[idx % CHART_PALETTE.length];
1225
1525
  const pts = b.track.filter((pt) => pt.t <= cutoff);
1226
1526
  if (!pts.length) return;
1227
1527
  const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
1228
1528
  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>`);
1529
+ pts.forEach((pt) => {
1530
+ const p = proj(pt);
1531
+ parts.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2" fill="${color}" opacity="0.7" />`);
1532
+ });
1533
+
1534
+ const prev = pts[pts.length - 1];
1535
+ let current = prev;
1536
+ let interpolated = false;
1537
+ if (!replayLive && prev.t < cutoff) {
1538
+ const next = b.track.find((pt) => pt.t > cutoff);
1539
+ if (next && next.t - prev.t <= MAX_INTERP_GAP_MS) {
1540
+ const frac = (cutoff - prev.t) / (next.t - prev.t);
1541
+ current = { lat: prev.lat + (next.lat - prev.lat) * frac, lon: prev.lon + (next.lon - prev.lon) * frac };
1542
+ interpolated = true;
1543
+ }
1544
+ }
1545
+ const cur = proj(current);
1546
+ if (interpolated) {
1547
+ 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" />`);
1548
+ } else {
1549
+ parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" />`);
1550
+ }
1551
+ 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
1552
  });
1233
1553
 
1234
1554
  courseChart.innerHTML = parts.join('');
@@ -1278,6 +1598,8 @@
1278
1598
  finishTime: b.finishTime,
1279
1599
  dnf: !!b.dnf,
1280
1600
  dnfPosition: b.dnfPosition || null,
1601
+ markTimes: b.markTimes || {},
1602
+ roundedMarksCount: b.roundedMarksCount || 0,
1281
1603
  elapsedMs,
1282
1604
  correctedMs,
1283
1605
  estimate,
@@ -1288,6 +1610,13 @@
1288
1610
  if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
1289
1611
  if (a.rankMs == null) return 1;
1290
1612
  if (b.rankMs == null) return -1;
1613
+ // Mirrors the server's rankedBoatList: with no AIS-based ETA to
1614
+ // fall back on, a boat recorded further around the course (by
1615
+ // either automatic AIS detection or a manually recorded rounding)
1616
+ // ranks ahead regardless of corrected time so far.
1617
+ if (!a.finishTime && !b.finishTime && !a.estimate && !b.estimate && a.roundedMarksCount !== b.roundedMarksCount) {
1618
+ return b.roundedMarksCount - a.roundedMarksCount;
1619
+ }
1291
1620
  return a.rankMs - b.rankMs;
1292
1621
  });
1293
1622
  }
@@ -1465,6 +1794,15 @@
1465
1794
 
1466
1795
  const tdElapsed = document.createElement('td');
1467
1796
  const tdCorrected = document.createElement('td');
1797
+ // One small pill button per course mark, in rounding order — click an
1798
+ // unrounded one to record "rounded now", click a rounded one to clear
1799
+ // it. Rebuilt only when the course's marks actually change (see
1800
+ // render()), not every tick. Independent of the automatic AIS
1801
+ // track-based rounding detection; either one counts (see the server's
1802
+ // countRoundedMarks) — this is the manual alternative, for a boat with
1803
+ // no MMSI/AIS at all or to correct a rounding the detection missed.
1804
+ const tdMarks = document.createElement('td');
1805
+ tdMarks.className = 'marks-cell';
1468
1806
  const tdEstFinish = document.createElement('td');
1469
1807
  tdEstFinish.className = 'est-finish';
1470
1808
  const tdVsSelf = document.createElement('td');
@@ -1539,12 +1877,15 @@
1539
1877
  const tdRemove = document.createElement('td');
1540
1878
  tdRemove.appendChild(removeBtn);
1541
1879
 
1542
- tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
1880
+ tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdMarks, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
1543
1881
 
1544
1882
  return {
1545
1883
  tr,
1546
1884
  selfBtn,
1547
1885
  nameSpan,
1886
+ tdMarks,
1887
+ marksSignature: undefined,
1888
+ marksPills: [],
1548
1889
  tdEstFinish,
1549
1890
  tdVsSelf,
1550
1891
  sailNumberInput,
@@ -1638,6 +1979,7 @@
1638
1979
  exportOfflineBtn.hidden = !raceState;
1639
1980
  courseSection.hidden = !raceState;
1640
1981
  raceImportSection.hidden = !raceState || !raceImportEnabled;
1982
+ renderStartTimer();
1641
1983
 
1642
1984
  if (!raceState) {
1643
1985
  emptyMsg.hidden = true;
@@ -1744,6 +2086,33 @@
1744
2086
  row.tdElapsed.textContent = fmtDuration(b.elapsedMs);
1745
2087
  row.tdCorrected.textContent = fmtDuration(b.correctedMs);
1746
2088
 
2089
+ const courseMarks = (raceState.course && raceState.course.marks) || [];
2090
+ const marksSig = courseMarks.map((m) => m.id).join(',');
2091
+ if (row.marksSignature !== marksSig) {
2092
+ row.tdMarks.innerHTML = '';
2093
+ row.marksPills = courseMarks.map((m, i) => {
2094
+ const pill = document.createElement('button');
2095
+ pill.type = 'button';
2096
+ pill.className = 'mark-pill';
2097
+ pill.textContent = String(i + 1);
2098
+ pill.addEventListener('click', () => {
2099
+ const boat = raceState.boats[b.boatId];
2100
+ const already = boat && boat.markTimes && boat.markTimes[m.id] != null;
2101
+ setMarkTime(b.boatId, m.id, already ? null : Date.now());
2102
+ });
2103
+ row.tdMarks.appendChild(pill);
2104
+ return pill;
2105
+ });
2106
+ row.marksSignature = marksSig;
2107
+ }
2108
+ courseMarks.forEach((m, i) => {
2109
+ const pill = row.marksPills[i];
2110
+ const t = b.markTimes && b.markTimes[m.id];
2111
+ pill.classList.toggle('rounded', t != null);
2112
+ const label = m.name || `Mark ${i + 1}`;
2113
+ pill.title = t != null ? `${label} — rounded ${new Date(t).toLocaleTimeString()} (click to clear)` : `${label} — click to mark rounded now`;
2114
+ });
2115
+
1747
2116
  if (b.dnf) {
1748
2117
  row.tdEstFinish.textContent = 'DNF';
1749
2118
  } else if (b.finishTime) {
@@ -1880,6 +2249,11 @@
1880
2249
  courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
1881
2250
  if (!courseBody.hidden) renderChart();
1882
2251
  });
2252
+ startTimerToggleBtn.addEventListener('click', () => {
2253
+ startTimerBody.hidden = !startTimerBody.hidden;
2254
+ startTimerToggleBtn.textContent = (startTimerBody.hidden ? '▸' : '▾') + ' Start timer';
2255
+ if (!startTimerBody.hidden) renderStartTimer();
2256
+ });
1883
2257
  raceImportToggleBtn.addEventListener('click', () => {
1884
2258
  raceImportBody.hidden = !raceImportBody.hidden;
1885
2259
  raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
@@ -1891,25 +2265,92 @@
1891
2265
  renderMarkRows();
1892
2266
  });
1893
2267
  saveCourseBtn.addEventListener('click', saveCourse);
2268
+
2269
+ // Runs the replay forward at replaySpeed simulated seconds per real
2270
+ // second, on its own timer (independent of the general 1s render tick)
2271
+ // so playback still looks reasonably smooth at low speeds. Stops itself
2272
+ // once it reaches the latest recorded position — there's nothing to play
2273
+ // into beyond that until more track is recorded.
2274
+ function stopReplayPlayback() {
2275
+ replayPlaying = false;
2276
+ replayPlayBtn.textContent = '▶ Play';
2277
+ if (replayPlayTimer) {
2278
+ clearInterval(replayPlayTimer);
2279
+ replayPlayTimer = null;
2280
+ }
2281
+ }
2282
+ function replayTick() {
2283
+ const now = Date.now();
2284
+ const elapsedMs = now - replayPlayLastTick;
2285
+ replayPlayLastTick = now;
2286
+ const maxT = Number(replaySlider.max);
2287
+ if (!isFinite(maxT)) {
2288
+ stopReplayPlayback();
2289
+ return;
2290
+ }
2291
+ replayTime = Math.min(replayTime + elapsedMs * replaySpeed, maxT);
2292
+ replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
2293
+ if (replayTime >= maxT) stopReplayPlayback();
2294
+ renderChart();
2295
+ }
2296
+ function startReplayPlayback() {
2297
+ if (replayPlaying) return;
2298
+ // Nothing to play forward into from Live — start over from the
2299
+ // earliest recorded position instead.
2300
+ if (replayLive) {
2301
+ const minT = Number(replaySlider.min);
2302
+ if (isFinite(minT)) replayTime = minT;
2303
+ }
2304
+ replayLive = false;
2305
+ replayPlaying = true;
2306
+ replayPlayBtn.textContent = '⏸ Pause';
2307
+ replayPlayLastTick = Date.now();
2308
+ replayPlayTimer = setInterval(replayTick, 200);
2309
+ renderChart();
2310
+ }
2311
+
1894
2312
  replaySlider.addEventListener('input', () => {
2313
+ stopReplayPlayback();
1895
2314
  replayLive = false;
1896
2315
  replayTime = Number(replaySlider.value);
1897
2316
  replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
1898
2317
  renderChart();
1899
2318
  });
2319
+ replayPlayBtn.addEventListener('click', () => {
2320
+ if (replayPlaying) stopReplayPlayback();
2321
+ else startReplayPlayback();
2322
+ });
2323
+ replaySpeedSlider.addEventListener('input', () => {
2324
+ replaySpeed = Number(replaySpeedSlider.value);
2325
+ replaySpeedLabel.textContent = replaySpeed + 'x';
2326
+ });
1900
2327
  replayLiveBtn.addEventListener('click', () => {
2328
+ stopReplayPlayback();
1901
2329
  replayLive = true;
1902
2330
  renderChart();
1903
2331
  });
1904
2332
 
2333
+ async function refreshSelfNav() {
2334
+ selfNav = await fetchSelfNav();
2335
+ renderStartTimer();
2336
+ }
2337
+
1905
2338
  async function init() {
1906
2339
  await loadVetEnabled();
1907
2340
  await loadRaceImportEnabled();
1908
- await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
2341
+ await Promise.all([
2342
+ loadVessels(),
2343
+ loadBoatRegistry(),
2344
+ loadHandicapRegister(false),
2345
+ loadWaypoints(),
2346
+ loadRacesList(),
2347
+ refreshSelfNav()
2348
+ ]);
1909
2349
  await loadRaceState();
1910
2350
  render();
1911
2351
  setInterval(render, 1000);
1912
2352
  setInterval(loadVessels, 10000);
2353
+ setInterval(refreshSelfNav, 3000);
1913
2354
  setInterval(() => {
1914
2355
  if (activeRaceId) loadRaceState();
1915
2356
  }, 5000);
package/public/index.html CHANGED
@@ -86,12 +86,40 @@
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>
92
95
  </div>
93
96
  </div>
94
97
 
98
+ <div id="startTimerSection" class="course-section" hidden>
99
+ <button id="startTimerToggleBtn" type="button" class="link-btn">▸ Start timer</button>
100
+ <div id="startTimerBody" hidden>
101
+ <div class="start-timer-grid">
102
+ <div class="start-timer-tile">
103
+ <div class="start-timer-label">Countdown to start</div>
104
+ <div id="startCountdownValue" class="start-timer-value">--:--:--</div>
105
+ </div>
106
+ <div class="start-timer-tile">
107
+ <div class="start-timer-label">Distance to line</div>
108
+ <div id="startDtlValue" class="start-timer-value">--</div>
109
+ </div>
110
+ <div class="start-timer-tile">
111
+ <div class="start-timer-label">ETA to line</div>
112
+ <div id="startEtaValue" class="start-timer-value">--:--:--</div>
113
+ </div>
114
+ <div class="start-timer-tile">
115
+ <div class="start-timer-label">Time to burn</div>
116
+ <div id="startBurnValue" class="start-timer-value">--</div>
117
+ </div>
118
+ </div>
119
+ <p id="startTimerNote" class="start-timer-note"></p>
120
+ </div>
121
+ </div>
122
+
95
123
  <div id="raceImportSection" class="course-section" hidden>
96
124
  <button id="raceImportToggleBtn" type="button" class="link-btn">▸ Import boats from Manage2Sail</button>
97
125
  <div id="raceImportBody" hidden>
@@ -127,6 +155,7 @@
127
155
  <th>Start time</th>
128
156
  <th>Elapsed</th>
129
157
  <th>Corrected</th>
158
+ <th>Marks</th>
130
159
  <th>Est. finish</th>
131
160
  <th>vs Self</th>
132
161
  <th>Finish time</th>
package/public/style.css CHANGED
@@ -226,6 +226,50 @@ main {
226
226
  padding-bottom: 1rem;
227
227
  }
228
228
 
229
+ .start-timer-grid {
230
+ margin-top: 0.75rem;
231
+ display: grid;
232
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
233
+ gap: 1rem;
234
+ max-width: 44rem;
235
+ }
236
+
237
+ .start-timer-tile {
238
+ background: var(--panel);
239
+ border: 1px solid var(--border);
240
+ border-radius: 8px;
241
+ padding: 0.75rem 1rem;
242
+ text-align: center;
243
+ }
244
+
245
+ .start-timer-label {
246
+ font-size: 0.7rem;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.04em;
249
+ color: var(--muted);
250
+ margin-bottom: 0.3rem;
251
+ }
252
+
253
+ .start-timer-value {
254
+ font-size: 1.4rem;
255
+ font-weight: 700;
256
+ font-variant-numeric: tabular-nums;
257
+ }
258
+
259
+ .start-timer-value.early {
260
+ color: var(--good);
261
+ }
262
+
263
+ .start-timer-value.late {
264
+ color: var(--bad);
265
+ }
266
+
267
+ .start-timer-note {
268
+ margin-top: 0.6rem;
269
+ font-size: 0.8rem;
270
+ color: var(--muted);
271
+ }
272
+
229
273
  .course-grid {
230
274
  margin-top: 0.75rem;
231
275
  display: grid;
@@ -257,8 +301,13 @@ main {
257
301
  align-items: center;
258
302
  }
259
303
 
260
- .course-point-row .course-name-input {
304
+ .course-point-row .course-name-autocomplete {
305
+ position: relative;
261
306
  width: 7rem;
307
+ }
308
+
309
+ .course-point-row .course-name-input {
310
+ width: 100%;
262
311
  padding: 0.3rem 0.4rem;
263
312
  background: var(--panel);
264
313
  color: var(--text);
@@ -364,6 +413,7 @@ main {
364
413
  .replay-controls {
365
414
  margin-top: 0.5rem;
366
415
  display: flex;
416
+ flex-wrap: wrap;
367
417
  align-items: center;
368
418
  gap: 0.6rem;
369
419
  max-width: 800px;
@@ -373,6 +423,11 @@ main {
373
423
  flex: 1;
374
424
  }
375
425
 
426
+ .replay-controls input[type='range'].replay-speed-slider {
427
+ flex: none;
428
+ width: 5rem;
429
+ }
430
+
376
431
  .replay-controls span {
377
432
  font-size: 0.8rem;
378
433
  color: var(--muted);
@@ -380,6 +435,37 @@ main {
380
435
  min-width: 5rem;
381
436
  }
382
437
 
438
+ .replay-controls span.replay-speed-label {
439
+ min-width: 2.2rem;
440
+ }
441
+
442
+ .marks-cell {
443
+ display: flex;
444
+ flex-wrap: wrap;
445
+ gap: 0.25rem;
446
+ min-width: 6rem;
447
+ }
448
+
449
+ .mark-pill {
450
+ width: 1.6rem;
451
+ height: 1.6rem;
452
+ padding: 0;
453
+ border-radius: 50%;
454
+ border: 1px solid var(--border);
455
+ background: transparent;
456
+ color: var(--muted);
457
+ font-size: 0.7rem;
458
+ font-weight: 700;
459
+ cursor: pointer;
460
+ line-height: 1;
461
+ }
462
+
463
+ .mark-pill.rounded {
464
+ background: var(--accent);
465
+ border-color: var(--accent);
466
+ color: #04202e;
467
+ }
468
+
383
469
  .est-finish {
384
470
  font-size: 0.85rem;
385
471
  color: var(--muted);