signalk-race-control 0.4.3 → 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,6 +73,14 @@ 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
86
  spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.4.3",
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
6
  "author": "Joachim Bakke <github@heiamoss.com>",
package/public/app.js CHANGED
@@ -85,6 +85,14 @@
85
85
  const replaySpeedSlider = document.getElementById('replaySpeedSlider');
86
86
  const replaySpeedLabel = document.getElementById('replaySpeedLabel');
87
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');
88
96
  const raceImportSection = document.getElementById('raceImportSection');
89
97
  const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
90
98
  const raceImportBody = document.getElementById('raceImportBody');
@@ -877,6 +885,123 @@
877
885
  return null;
878
886
  }
879
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
+
880
1005
  // Generic substring-match autocomplete, wiring `input` to `dropdown` (a
881
1006
  // hidden .suggestions element already sitting next to it in the DOM) —
882
1007
  // same matching/keyboard-nav behavior as the boat-name autocomplete
@@ -1854,6 +1979,7 @@
1854
1979
  exportOfflineBtn.hidden = !raceState;
1855
1980
  courseSection.hidden = !raceState;
1856
1981
  raceImportSection.hidden = !raceState || !raceImportEnabled;
1982
+ renderStartTimer();
1857
1983
 
1858
1984
  if (!raceState) {
1859
1985
  emptyMsg.hidden = true;
@@ -2123,6 +2249,11 @@
2123
2249
  courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
2124
2250
  if (!courseBody.hidden) renderChart();
2125
2251
  });
2252
+ startTimerToggleBtn.addEventListener('click', () => {
2253
+ startTimerBody.hidden = !startTimerBody.hidden;
2254
+ startTimerToggleBtn.textContent = (startTimerBody.hidden ? '▸' : '▾') + ' Start timer';
2255
+ if (!startTimerBody.hidden) renderStartTimer();
2256
+ });
2126
2257
  raceImportToggleBtn.addEventListener('click', () => {
2127
2258
  raceImportBody.hidden = !raceImportBody.hidden;
2128
2259
  raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
@@ -2199,14 +2330,27 @@
2199
2330
  renderChart();
2200
2331
  });
2201
2332
 
2333
+ async function refreshSelfNav() {
2334
+ selfNav = await fetchSelfNav();
2335
+ renderStartTimer();
2336
+ }
2337
+
2202
2338
  async function init() {
2203
2339
  await loadVetEnabled();
2204
2340
  await loadRaceImportEnabled();
2205
- await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadWaypoints(), loadRacesList()]);
2341
+ await Promise.all([
2342
+ loadVessels(),
2343
+ loadBoatRegistry(),
2344
+ loadHandicapRegister(false),
2345
+ loadWaypoints(),
2346
+ loadRacesList(),
2347
+ refreshSelfNav()
2348
+ ]);
2206
2349
  await loadRaceState();
2207
2350
  render();
2208
2351
  setInterval(render, 1000);
2209
2352
  setInterval(loadVessels, 10000);
2353
+ setInterval(refreshSelfNav, 3000);
2210
2354
  setInterval(() => {
2211
2355
  if (activeRaceId) loadRaceState();
2212
2356
  }, 5000);
package/public/index.html CHANGED
@@ -95,6 +95,31 @@
95
95
  </div>
96
96
  </div>
97
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
+
98
123
  <div id="raceImportSection" class="course-section" hidden>
99
124
  <button id="raceImportToggleBtn" type="button" class="link-btn">▸ Import boats from Manage2Sail</button>
100
125
  <div id="raceImportBody" hidden>
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;