signalk-race-control 0.11.1 → 0.12.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
@@ -170,7 +170,27 @@ and a live projected finishing order — during and after the race.
170
170
  waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
171
171
  standard resources API — both directions (reading existing waypoints for the
172
172
  autocomplete, and publishing the saved course) only do anything if your server has a
173
- resources provider installed; they're a no-op otherwise, never a failure.
173
+ resources provider installed; they're a no-op otherwise, never a failure. Once a race
174
+ has started, the moment this vessel's own position actually crosses the start line
175
+ (a real crossing between two GPS fixes, not just getting close to it), the course is
176
+ also activated as this vessel's SignalK navigation route — so a chart plotter reading
177
+ the standard Course API starts showing/guiding to the next mark automatically, without
178
+ anyone having to find and select the route by hand right as the race gets underway.
179
+ Requires both a resources provider and a server new enough to have the Course API;
180
+ a no-op otherwise. Whenever this vessel's own true wind direction and a moving SOG
181
+ are available from SignalK, the chart also draws a pair of 10-minute laylines from its
182
+ live position — where a further 10 minutes on each close-hauled tack would put it,
183
+ using a generic ~40° tack angle rather than this boat's own polar data. Absent
184
+ whenever that wind/speed data isn't, same as everything else on this chart that
185
+ depends on live SignalK data. A **Wind shift** instrument sits above the chart
186
+ alongside it, once there's enough live wind history (30s or more) to call a trend
187
+ from: it tracks a rolling ~3-minute average true wind direction as a baseline and
188
+ compares the current reading against it, reporting **Lift** or **Header** (with the
189
+ shift in degrees) or **Steady** below a small deadband — tack-aware, since the same
190
+ shift is a lift on one tack and a header on the other, using this vessel's own
191
+ heading to tell which. It's a wind-trend read, not a mark-aware tactical simulator —
192
+ no probability-of-gain number, just whether the breeze has moved for or against
193
+ whichever tack you're currently on.
174
194
  - **Estimated finish time & live rank** — while a boat is still racing, if it has a
175
195
  live AIS position and speed and the race has a finish line, the plugin projects a
176
196
  finish time and corrected time from its remaining distance and speed, and ranks it
package/index.js CHANGED
@@ -305,6 +305,7 @@ function ensureRaceShape(race) {
305
305
  if (race.stopTime === undefined) race.stopTime = null;
306
306
  if (race.scheduledCallOff === undefined) race.scheduledCallOff = null;
307
307
  if (race.multiDay === undefined) race.multiDay = false;
308
+ if (race.courseActivated === undefined) race.courseActivated = false;
308
309
  if (!race.classes) race.classes = [];
309
310
  race.classes.forEach((c) => {
310
311
  if (c.startTime === undefined) c.startTime = null;
@@ -364,6 +365,22 @@ function midpoint(a, b) {
364
365
  return { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
365
366
  }
366
367
 
368
+ // True if segment p1-p2 crosses segment p3-p4 — the standard orientation
369
+ // (cross-product sign) test. Treats lat/lon as a flat plane, fine at the
370
+ // scale of a start line (tens to low hundreds of meters). Used to detect a
371
+ // boat crossing the start line between two consecutive position samples;
372
+ // doesn't distinguish direction (inbound vs outbound) or handle the
373
+ // collinear/touching edge case — a crossing landing exactly on that is
374
+ // astronomically unlikely, and would just be caught on the next sample.
375
+ function segmentsIntersect(p1, p2, p3, p4) {
376
+ const cross = (o, a, b) => (a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon);
377
+ const d1 = cross(p3, p4, p1);
378
+ const d2 = cross(p3, p4, p2);
379
+ const d3 = cross(p1, p2, p3);
380
+ const d4 = cross(p1, p2, p4);
381
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
382
+ }
383
+
367
384
  function validateCoordPoint(p) {
368
385
  if (!p || typeof p.lat !== 'number' || typeof p.lon !== 'number' || !isFinite(p.lat) || !isFinite(p.lon)) {
369
386
  return null;
@@ -1600,6 +1617,7 @@ module.exports = function (app) {
1600
1617
  let dataFile = null;
1601
1618
  const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
1602
1619
  const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
1620
+ let lastSelfPos = null; // {lat, lon} as of the previous checkStartLineCrossings tick
1603
1621
 
1604
1622
  // setTimeout's delay is a 32-bit signed int internally — anything past
1605
1623
  // ~24.8 days silently wraps and fires almost immediately instead of
@@ -1988,6 +2006,48 @@ module.exports = function (app) {
1988
2006
  if (changed) saveState();
1989
2007
  }
1990
2008
 
2009
+ // Once a race has started and this vessel (this SignalK server's own
2010
+ // position, not any boat in the fleet) actually crosses its start line,
2011
+ // activate that race's course as the vessel's current SignalK navigation
2012
+ // route — so a chart plotter (freeboard-sk etc.) reading the standard
2013
+ // Course API immediately starts showing/guiding to the next mark,
2014
+ // without anyone having to find and select the route by hand right as
2015
+ // the race gets underway. Detected as a genuine line crossing between
2016
+ // two consecutive position samples (not just proximity), same 15s
2017
+ // cadence as recordTrackSample. Best-effort and silently skipped if this
2018
+ // server has no Course API (older signalk-server) or no resourcesApi —
2019
+ // core race-control features never depend on this succeeding.
2020
+ async function checkStartLineCrossings() {
2021
+ let selfPos = null;
2022
+ try {
2023
+ const leaf = app.getSelfPath('navigation.position');
2024
+ const v = leaf && leaf.value;
2025
+ if (v && typeof v.latitude === 'number' && typeof v.longitude === 'number') {
2026
+ selfPos = { lat: v.latitude, lon: v.longitude };
2027
+ }
2028
+ } catch (e) {
2029
+ // no self position available yet
2030
+ }
2031
+ const prevPos = lastSelfPos;
2032
+ lastSelfPos = selfPos;
2033
+ if (!selfPos || !prevPos || typeof app.activateRoute !== 'function') return;
2034
+ for (const race of Object.values(state.races)) {
2035
+ if (!race.startTime || race.stopTime || race.courseActivated) continue;
2036
+ const line = race.course && race.course.startLine;
2037
+ if (!line) continue;
2038
+ if (!segmentsIntersect(prevPos, selfPos, line[0], line[1])) continue;
2039
+ try {
2040
+ await publishCourseResources(race);
2041
+ await app.activateRoute({ href: `/resources/routes/${deterministicUuid(`race-${race.id}-course`)}`, pointIndex: 0 });
2042
+ race.courseActivated = true;
2043
+ saveState();
2044
+ app.debug(`race-control: activated course for race "${race.name}" after crossing the start line`);
2045
+ } catch (e) {
2046
+ app.debug('race-control: could not activate course in SignalK: ' + e.message);
2047
+ }
2048
+ }
2049
+ }
2050
+
1991
2051
  function disarmSchedule(raceId) {
1992
2052
  const t = scheduleTimers.get(raceId);
1993
2053
  if (t) {
@@ -2002,6 +2062,7 @@ module.exports = function (app) {
2002
2062
  race.startTime = atTime;
2003
2063
  race.scheduledStart = null;
2004
2064
  race.stopTime = null;
2065
+ race.courseActivated = false;
2005
2066
  Object.values(race.boats).forEach((b) => {
2006
2067
  b.finishTime = null;
2007
2068
  b.startTime = null;
@@ -2151,7 +2212,10 @@ module.exports = function (app) {
2151
2212
  armSchedule(race);
2152
2213
  armCallOffSchedule(race);
2153
2214
  });
2154
- trackTimer = setInterval(recordTrackSample, 15000);
2215
+ trackTimer = setInterval(() => {
2216
+ recordTrackSample();
2217
+ checkStartLineCrossings().catch((e) => app.debug('race-control: checkStartLineCrossings failed: ' + e.message));
2218
+ }, 15000);
2155
2219
  app.setPluginStatus('Race control ready');
2156
2220
  };
2157
2221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.11.1",
3
+ "version": "0.12.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
@@ -125,6 +125,10 @@
125
125
  const startEtaValue = document.getElementById('startEtaValue');
126
126
  const startBurnValue = document.getElementById('startBurnValue');
127
127
  const startTimerNote = document.getElementById('startTimerNote');
128
+ const windShiftBar = document.getElementById('windShiftBar');
129
+ const windShiftValue = document.getElementById('windShiftValue');
130
+ const windShiftTack = document.getElementById('windShiftTack');
131
+ const windShiftTwd = document.getElementById('windShiftTwd');
128
132
  const raceImportSection = document.getElementById('raceImportSection');
129
133
  const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
130
134
  const raceImportBody = document.getElementById('raceImportBody');
@@ -1179,18 +1183,43 @@
1179
1183
  // start-line transit timer app shows, computed from this vessel's own
1180
1184
  // live position/speed (not any other boat's).
1181
1185
 
1182
- let selfNav = null; // {lat, lon, sogMs} | null, refreshed on its own poll — see below
1186
+ let selfNav = null; // {lat, lon, sogMs, windDirTrueRad, headingRad} | null, refreshed on its own poll — see below
1187
+
1188
+ function normalizeRad(a) {
1189
+ const twoPi = Math.PI * 2;
1190
+ return ((a % twoPi) + twoPi) % twoPi;
1191
+ }
1183
1192
 
1184
1193
  async function fetchSelfNav() {
1185
1194
  try {
1186
- const [posLeaf, sogLeaf] = await Promise.all([
1195
+ const [posLeaf, sogLeaf, headingLeaf, cogLeaf, windDirLeaf, windAngleGroundLeaf, windAngleWaterLeaf] = await Promise.all([
1187
1196
  fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
1188
- fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
1197
+ fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null),
1198
+ fetchJSON(`${SK_API}/vessels/self/navigation/headingTrue`).catch(() => null),
1199
+ fetchJSON(`${SK_API}/vessels/self/navigation/courseOverGroundTrue`).catch(() => null),
1200
+ fetchJSON(`${SK_API}/vessels/self/environment/wind/directionTrue`).catch(() => null),
1201
+ fetchJSON(`${SK_API}/vessels/self/environment/wind/angleTrueGround`).catch(() => null),
1202
+ fetchJSON(`${SK_API}/vessels/self/environment/wind/angleTrueWater`).catch(() => null)
1189
1203
  ]);
1190
1204
  const v = posLeaf && posLeaf.value;
1191
1205
  if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') return null;
1192
1206
  const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
1193
- return { lat: v.latitude, lon: v.longitude, sogMs };
1207
+ // True wind direction, absolute (radians, 0 = north) — prefer a
1208
+ // sensor/instrument that already computed it; otherwise derive it
1209
+ // from a true wind ANGLE (relative to the bow) plus heading (or, if
1210
+ // no compass, course over ground as a rough stand-in). null — not
1211
+ // just a missing layline — if none of these are available, per
1212
+ // SignalK's own "best-effort, never a failure" convention elsewhere
1213
+ // in this app.
1214
+ let windDirTrueRad = null;
1215
+ const heading = headingLeaf && typeof headingLeaf.value === 'number' ? headingLeaf.value : cogLeaf && typeof cogLeaf.value === 'number' ? cogLeaf.value : null;
1216
+ if (windDirLeaf && typeof windDirLeaf.value === 'number') {
1217
+ windDirTrueRad = normalizeRad(windDirLeaf.value);
1218
+ } else if (heading != null) {
1219
+ const angle = windAngleGroundLeaf && typeof windAngleGroundLeaf.value === 'number' ? windAngleGroundLeaf.value : windAngleWaterLeaf && typeof windAngleWaterLeaf.value === 'number' ? windAngleWaterLeaf.value : null;
1220
+ if (angle != null) windDirTrueRad = normalizeRad(heading + angle);
1221
+ }
1222
+ return { lat: v.latitude, lon: v.longitude, sogMs, windDirTrueRad, headingRad: heading };
1194
1223
  } catch (e) {
1195
1224
  return null;
1196
1225
  }
@@ -1200,6 +1229,95 @@
1200
1229
  function toRad(deg) {
1201
1230
  return (deg * Math.PI) / 180;
1202
1231
  }
1232
+ function toDeg(rad) {
1233
+ return (rad * 180) / Math.PI;
1234
+ }
1235
+
1236
+ const EARTH_RADIUS_M = 6371000;
1237
+ // Destination point a given bearing/distance from a start point (great-
1238
+ // circle "direct" geodesic problem) — used to draw laylines a fixed
1239
+ // sailing distance out from the boat's own position, same spherical
1240
+ // approximation as everywhere else on this chart.
1241
+ function destinationPoint(pt, bearingRad, distanceM) {
1242
+ const delta = distanceM / EARTH_RADIUS_M;
1243
+ const phi1 = toRad(pt.lat);
1244
+ const lambda1 = toRad(pt.lon);
1245
+ const phi2 = Math.asin(Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(bearingRad));
1246
+ const lambda2 = lambda1 + Math.atan2(Math.sin(bearingRad) * Math.sin(delta) * Math.cos(phi1), Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2));
1247
+ return { lat: toDeg(phi2), lon: toDeg(lambda2) };
1248
+ }
1249
+
1250
+ // ---- Wind shift / tack-or-hold --------------------------------------
1251
+ // A lift/header indicator: is the current true wind direction shifted
1252
+ // toward this vessel's advantage on whichever tack it's on right now
1253
+ // (favors holding), or away from it (favors tacking onto the shift)?
1254
+ // Tracks a short rolling average of true wind direction as the
1255
+ // "baseline" — the same live SignalK feed the laylines use — since a
1256
+ // helm sailing closehauled keeps adjusting course to hold a steady angle
1257
+ // to the wind, so watching the boat's own heading wouldn't show a shift
1258
+ // that's already been steered around; watching the wind's own bearing
1259
+ // trend does.
1260
+ const WIND_HISTORY_WINDOW_MS = 3 * 60 * 1000; // rolling baseline span
1261
+ const WIND_HISTORY_MIN_SPAN_MS = 30 * 1000; // need at least this much history before calling a shift
1262
+ const WIND_SHIFT_DEADBAND_DEG = 4; // smaller than this reads as "steady", not a real shift
1263
+ let windHistory = []; // [{t, rad}], trimmed to WIND_HISTORY_WINDOW_MS
1264
+
1265
+ function normalizeSignedRad(a) {
1266
+ const twoPi = Math.PI * 2;
1267
+ let r = a % twoPi;
1268
+ if (r > Math.PI) r -= twoPi;
1269
+ if (r < -Math.PI) r += twoPi;
1270
+ return r;
1271
+ }
1272
+
1273
+ // Angles wrap, so a plain arithmetic mean is wrong near the wrap point —
1274
+ // average the unit vectors instead (standard circular mean).
1275
+ function circularMeanRad(radians) {
1276
+ let sumSin = 0;
1277
+ let sumCos = 0;
1278
+ radians.forEach((r) => {
1279
+ sumSin += Math.sin(r);
1280
+ sumCos += Math.cos(r);
1281
+ });
1282
+ return Math.atan2(sumSin, sumCos);
1283
+ }
1284
+
1285
+ // null until there's a moving self position, a true wind direction, a
1286
+ // heading to determine tack from, and enough rolling history to call a
1287
+ // baseline — same "if available, silently not otherwise" spirit as the
1288
+ // laylines above.
1289
+ function computeWindShift() {
1290
+ if (!selfNav || selfNav.windDirTrueRad == null || selfNav.headingRad == null) return null;
1291
+ if (!windHistory.length) return null;
1292
+ const span = windHistory[windHistory.length - 1].t - windHistory[0].t;
1293
+ if (span < WIND_HISTORY_MIN_SPAN_MS) return null;
1294
+ const baselineRad = circularMeanRad(windHistory.map((s) => s.rad));
1295
+ const shiftRad = normalizeSignedRad(selfNav.windDirTrueRad - baselineRad);
1296
+ const shiftDeg = toDeg(shiftRad);
1297
+ // Wind source to the right of the bow (positive) = starboard tack.
1298
+ const tack = normalizeSignedRad(selfNav.windDirTrueRad - selfNav.headingRad) > 0 ? 'starboard' : 'port';
1299
+ let verdict = 'steady';
1300
+ if (Math.abs(shiftDeg) >= WIND_SHIFT_DEADBAND_DEG) {
1301
+ const isLift = tack === 'starboard' ? shiftDeg > 0 : shiftDeg < 0;
1302
+ verdict = isLift ? 'lift' : 'header';
1303
+ }
1304
+ return { tack, verdict, shiftDeg, twdDeg: (toDeg(selfNav.windDirTrueRad) + 360) % 360 };
1305
+ }
1306
+
1307
+ function renderWindShift() {
1308
+ const result = computeWindShift();
1309
+ windShiftBar.hidden = !result;
1310
+ if (!result) return;
1311
+ windShiftTack.textContent = result.tack === 'starboard' ? 'Starboard' : 'Port';
1312
+ windShiftTwd.textContent = `${Math.round(result.twdDeg)}°`;
1313
+ windShiftValue.className = 'start-timer-value';
1314
+ if (result.verdict === 'steady') {
1315
+ windShiftValue.textContent = 'Steady';
1316
+ } else {
1317
+ windShiftValue.textContent = `${result.verdict === 'lift' ? 'Lift' : 'Header'} ${result.shiftDeg >= 0 ? '+' : ''}${Math.round(result.shiftDeg)}°`;
1318
+ windShiftValue.classList.add(result.verdict);
1319
+ }
1320
+ }
1203
1321
 
1204
1322
  // Perpendicular distance from a point to the start-line segment (or to
1205
1323
  // its nearest end, if the point doesn't fall between the two ends) — a
@@ -2225,6 +2343,27 @@
2225
2343
  .addTo(chartLayerGroup);
2226
2344
  });
2227
2345
 
2346
+ // Laylines for this vessel — where a further 10 minutes on each
2347
+ // close-hauled tack would put it, from wherever it is right now. Shown
2348
+ // only when there's actually a true wind direction and a moving SOG to
2349
+ // draw them from (not just a position); silently absent otherwise,
2350
+ // same "if available" spirit as the rest of this app's SignalK-fed
2351
+ // instruments. LAYLINE_TACK_ANGLE_DEG is a generic close-hauled
2352
+ // assumption, not this boat's own polar data — good enough for a rough
2353
+ // visual, not a precise VMG angle.
2354
+ const LAYLINE_TACK_ANGLE_DEG = 40;
2355
+ const LAYLINE_DURATION_S = 10 * 60;
2356
+ if (selfNav && selfNav.windDirTrueRad != null && selfNav.sogMs != null && selfNav.sogMs >= 0.25) {
2357
+ const distanceM = selfNav.sogMs * LAYLINE_DURATION_S;
2358
+ const tackAngleRad = toRad(LAYLINE_TACK_ANGLE_DEG);
2359
+ [selfNav.windDirTrueRad + tackAngleRad, selfNav.windDirTrueRad - tackAngleRad].forEach((bearingRad) => {
2360
+ const end = destinationPoint(selfNav, normalizeRad(bearingRad), distanceM);
2361
+ L.polyline([ll(selfNav), ll(end)], { color: 'var(--accent)', weight: 1.5, dashArray: '2,6' })
2362
+ .bindTooltip('<span style="color:var(--accent)">10min layline</span>', { sticky: true, className: 'mark-label' })
2363
+ .addTo(chartLayerGroup);
2364
+ });
2365
+ }
2366
+
2228
2367
  let minT = Infinity;
2229
2368
  let maxT = -Infinity;
2230
2369
  boatsWithTrack.forEach((b) => {
@@ -2811,6 +2950,7 @@
2811
2950
  classSection.hidden = !raceState;
2812
2951
  raceImportSection.hidden = !raceState || !raceImportEnabled;
2813
2952
  renderStartTimer();
2953
+ renderWindShift();
2814
2954
 
2815
2955
  if (!raceState) {
2816
2956
  emptyMsg.hidden = true;
@@ -3112,6 +3252,7 @@
3112
3252
  courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
3113
3253
  if (!courseBody.hidden) {
3114
3254
  renderChart();
3255
+ renderWindShift();
3115
3256
  // The map may have been created (or last sized) while its container
3116
3257
  // was hidden or a different size — Leaflet doesn't pick that up on
3117
3258
  // its own.
@@ -3220,7 +3361,13 @@
3220
3361
 
3221
3362
  async function refreshSelfNav() {
3222
3363
  selfNav = await fetchSelfNav();
3364
+ if (selfNav && selfNav.windDirTrueRad != null) {
3365
+ const now = Date.now();
3366
+ windHistory.push({ t: now, rad: selfNav.windDirTrueRad });
3367
+ while (windHistory.length && now - windHistory[0].t > WIND_HISTORY_WINDOW_MS) windHistory.shift();
3368
+ }
3223
3369
  renderStartTimer();
3370
+ renderWindShift();
3224
3371
  }
3225
3372
 
3226
3373
  async function init() {
package/public/index.html CHANGED
@@ -159,6 +159,20 @@
159
159
  <span id="courseStatusText" class="status"></span>
160
160
  </div>
161
161
  </div>
162
+ <div id="windShiftBar" class="start-timer-grid wind-shift-grid" hidden>
163
+ <div class="start-timer-tile">
164
+ <div class="start-timer-label">Wind shift</div>
165
+ <div id="windShiftValue" class="start-timer-value">--</div>
166
+ </div>
167
+ <div class="start-timer-tile">
168
+ <div class="start-timer-label">Tack</div>
169
+ <div id="windShiftTack" class="start-timer-value">--</div>
170
+ </div>
171
+ <div class="start-timer-tile">
172
+ <div class="start-timer-label">True wind</div>
173
+ <div id="windShiftTwd" class="start-timer-value">--</div>
174
+ </div>
175
+ </div>
162
176
  <div class="chart-wrap">
163
177
  <div id="courseChart" class="chart-map"></div>
164
178
  <p id="chartEmptyMsg" class="empty chart-empty-msg" hidden>Add a course, then start the race, to see the chart here</p>
package/public/style.css CHANGED
@@ -276,6 +276,18 @@ main {
276
276
  color: var(--muted);
277
277
  }
278
278
 
279
+ .wind-shift-grid {
280
+ margin-bottom: 1rem;
281
+ }
282
+
283
+ .start-timer-value.lift {
284
+ color: var(--good);
285
+ }
286
+
287
+ .start-timer-value.header {
288
+ color: var(--bad);
289
+ }
290
+
279
291
  .course-grid {
280
292
  margin-top: 0.75rem;
281
293
  display: flex;