signalk-racer 1.0.0 → 1.1.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
@@ -24,6 +24,8 @@ This plugin calculates and publishes the following Signal K paths:
24
24
  | `navigation.racing.`<br/>`nextLegHeading` | True heading for the next leg of the course | `rad` | |
25
25
  | `navigation.racing.`<br/>`nextLegTrueWindAngle` | True Wind Angle for the next leg of the course | `rad` | |
26
26
  | `navigation.racing.`<br/>`timeToStart` | Period of time until the race start | `s` | Y |
27
+ | `navigation.racing.`<br/>`timeToLine` | Period of time to sail to the line at best VMG | `s` | |
28
+ | `navigation.racing.`<br/>`timeToBurn` | Period of time delay before sailing to the at best VMG | `s` | |
27
29
  | `navigation.racing.`<br/>`startTime` | The start time as an ISO timestamp | `rfc3339` | |
28
30
 
29
31
  These values can be displayed in KIP widgets, Freeboard-SK, or other Signal K clients.
@@ -51,6 +53,7 @@ You can configure the plugin via the Signal K web interface or by editing `setti
51
53
  | `period` | How often to update values (in milliseconds) | `1000` |
52
54
  | `updateStartLineWaypoint` | Should the waypoints be updated if the line is set/adjusted | `true` |
53
55
  | `createStartLineWaypoint` | Should the waypoints be created if the line is set | `true` |
56
+ | `lines` | Array of named lines | null |
54
57
 
55
58
  ---
56
59
 
@@ -66,13 +69,16 @@ If `navigation.headingTrue` is not available, then `navigation.courseOverGroundT
66
69
 
67
70
  ### Distance to Start Line (`navigation.racing.distanceStartline`)
68
71
 
69
- - Computes the **signed minimum distance** from the **bow** of the boat to the configured start line.
72
+ ![Distance to Start Line](racer-line.png)
73
+
74
+ - Computes the distance from the **bow** of the boat to the configured start line.
75
+ - For boats within the start zone (defined as with a 45 degree bearing to each end of the line), the perpendicular distance to the line is used.
76
+ - For boats outside of the start zone, the distance parallel to the line to reach the zone is added to their perpendicular distance to the line.
70
77
  - Uses `sensors.gps.fromBow` and `sensors.gps.fromCenter` offsets to calculate the actual bow position.
71
- - Normally uses the **perpendicular projection** from the bow to the line.
72
- - However, if the bow is located within a **45° sector** off either end of the line, the plugin adds the distance back to that diagonal to perpendicular distance.
73
78
  - The result is:
74
79
  - **Negative** when the bow is **over the line (OCS)**
75
80
  - **Positive** when the bow is **behind the line**
81
+ - If the boat is navigating a route and the next waypoint is beyond the second in the route, then the distance to line is not calculated.
76
82
 
77
83
  ### Start Line Bias (`navigation.racing.stbLineBias`)
78
84
 
@@ -80,6 +86,20 @@ If `navigation.headingTrue` is not available, then `navigation.courseOverGroundT
80
86
  - The `environment.wind.directionTrue` is used as well as the calculated line length and bearing.
81
87
  - A **positive value** means the **pin end is more downwind**, implying the **starboard (boat) end is favored** for upwind starts.
82
88
 
89
+ ### Time To Line (`navigation.racing.timeToLine`)
90
+
91
+ - Uses the effective VMG as the maximum VMG of either the current COG/SOG or the 90th percentile of the VMG recently achieved (approx in the last 10 minutes).
92
+ This allows a boat to luff / delay without changing the time to line.
93
+ - A boat within the start zone has the time calculated by the perpendicular distance to the line divided by their effective VMG to the line.
94
+ - A boat outside the start zone also has the perpendicular time plus the time calculated by the parallel distance to the zone divided by their effective VMG in that direction.
95
+ - If the line is changed, then the samples used to calculate the effective VMGs are cleared.
96
+ - If the boat is navigating a route and the next waypoint is beyond the second in the route, then the time to line is not calculated.
97
+
98
+
99
+ ### Time To Burn (`navigation.racing.timeToBurn`)
100
+ - If the start timer is running and the boat is not OCS, then the time to burn is calculated by subtracting the time to line from the remaining start time.
101
+ - If the boat is navigating a route and the next waypoint is beyond the second in the route, then the time to line is not calculated.
102
+
83
103
  ### Line Coordinates & Length
84
104
 
85
105
  - The plugin reads Signal K `waypoints` resources for the names you configure.
@@ -152,7 +172,7 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
152
172
  ## 🧪 Future Plans
153
173
 
154
174
  - Add calculations for:
155
- - **Time to line** (using STW and heading or polar speeds)
175
+ - **Time to burn**
156
176
  - **Laylines** and distance/time to them
157
177
 
158
178
  ---
@@ -160,3 +180,13 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
160
180
 
161
181
  Bug reports and suggestions are welcome at
162
182
  [https://github.com/gregw/signalk-racer](https://github.com/gregw/signalk-racer)
183
+
184
+
185
+ ---
186
+ ## Development
187
+ ```text
188
+ npm install
189
+ npm link
190
+ cd ~/.signalk
191
+ npm link signalk-racer
192
+ ```
package/ReleaseNotes.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 1.1.0 Named lines
2
+ + Added Named lines support
3
+ + Refactored distance to line calculations
4
+ + Added timeToStart and timeToBurn (experimental at this stage)
5
+ + Minor bug fixes
6
+
7
+ ## 1.0.1 Fix initial configuration
8
+ + Fixed default values of initial configuration
9
+
1
10
  ## 1.0.0 GA Release with KIP Widgets
2
11
  + Fixed bias to be towards wind
3
12
 
package/index.js CHANGED
@@ -1,5 +1,13 @@
1
1
  module.exports = (app) => {
2
- const state = {};
2
+ const state = {
3
+ startLineName: null,
4
+ startLine: null,
5
+
6
+ vmgToLinePos: [], // VMG towards the line (not OCS to line)
7
+ vmgToLineNeg: [], // VMG away from the line (OCS to line)
8
+ vmgToZonePort: [], // Motion along the line towards the PORT end (pin)
9
+ vmgToZoneStb: [], // Motion along the line towards the STARBOARD end (boat)
10
+ };
3
11
  const geolib = require('geolib')
4
12
  const racerSchema = {
5
13
  title: 'Signalk Racer Configuration',
@@ -7,23 +15,23 @@ module.exports = (app) => {
7
15
  properties: {
8
16
  startLineStb: {
9
17
  type: 'string',
10
- title: 'The start line starboard end (boat) waypoint name',
18
+ title: 'Start line starboard-end (boat) waypoint name',
11
19
  default: 'startBoat'
12
20
  },
13
21
  startLinePort: {
14
22
  type: 'string',
15
- title: 'The start line port end (pin) waypoint name',
23
+ title: 'Start line port-end (pin) waypoint name',
16
24
  default: 'startPin'
17
25
  },
18
26
  updateStartLineWaypoint: {
19
27
  type: 'boolean',
20
28
  title: 'Update the start line waypoints if the startLine is updated',
21
- default: 'true'
29
+ default: true
22
30
  },
23
31
  createStartLineWaypoint: {
24
32
  type: 'boolean',
25
33
  title: 'Create the start line waypoints if the startLine is updated and the waypoint does not exist',
26
- default: 'true'
34
+ default: true
27
35
  },
28
36
  period: {
29
37
  type: 'number',
@@ -35,6 +43,45 @@ module.exports = (app) => {
35
43
  title: 'Start Timer default initial period in seconds',
36
44
  default: 300
37
45
  },
46
+
47
+ lines: {
48
+ type: 'array',
49
+ title: 'Named lines',
50
+ description: 'Add zero or more named start lines',
51
+ items: {
52
+ type: 'object',
53
+ title: 'Named line',
54
+ properties: {
55
+ startLineName: {
56
+ type: 'string',
57
+ title: 'Line name',
58
+ },
59
+ startLineStb: {
60
+ type: 'string',
61
+ title: 'Start line starboard-end (boat) waypoint name'
62
+ },
63
+ startLinePort: {
64
+ type: 'string',
65
+ title: 'Start line port-end (pin) waypoint name'
66
+ },
67
+ updateStartLineWaypoint: {
68
+ type: 'boolean',
69
+ title: 'Update the start line waypoints if the startLine is updated',
70
+ default: true
71
+ },
72
+ createStartLineWaypoint: {
73
+ type: 'boolean',
74
+ title: 'Create the start line waypoints if the startLine is updated and the waypoint does not exist',
75
+ default: true
76
+ },
77
+ startLineDescription: {
78
+ type: 'string',
79
+ title: 'Optional description'
80
+ }
81
+ },
82
+ required: ['startLineName', 'startLineStb', 'startLinePort']
83
+ }
84
+ }
38
85
  }
39
86
  }
40
87
  const { v4: uuidv4 } = require('uuid')
@@ -100,6 +147,26 @@ module.exports = (app) => {
100
147
  "shortName": "StartTime",
101
148
  }
102
149
  },
150
+ {
151
+ "path": "navigation.racing.timeToLine",
152
+ "value": {
153
+ "type": "number",
154
+ "units": "s",
155
+ "description": "Time to sail to the the line at best VMG",
156
+ "displayName": "Time to line",
157
+ "shortName": "TTS"
158
+ }
159
+ },
160
+ {
161
+ "path": "navigation.racing.timeToBurn",
162
+ "value": {
163
+ "type": "number",
164
+ "units": "s",
165
+ "description": "Time to delay before sailing to the the start line",
166
+ "displayName": "Time to burn",
167
+ "shortName": "TTB"
168
+ }
169
+ }
103
170
  ]
104
171
  }
105
172
  ]
@@ -144,11 +211,13 @@ module.exports = (app) => {
144
211
  }
145
212
 
146
213
  function toDegrees(rad) {
147
- return rad ? rad * (180 / Math.PI) : null;
214
+ if (rad === null || rad === undefined) return null;
215
+ return rad * (180 / Math.PI);
148
216
  }
149
217
 
150
218
  function toRadians(degrees) {
151
- return degrees ? degrees * (Math.PI / 180) : null;
219
+ if (degrees === null || degrees === undefined) return null;
220
+ return degrees * (Math.PI / 180);
152
221
  }
153
222
 
154
223
  function toOtherEnd(end) {
@@ -170,17 +239,12 @@ module.exports = (app) => {
170
239
  return position
171
240
 
172
241
  headingTrue = toDegrees(headingTrue.value);
173
- app.debug('headingTrue:' + headingTrue);
174
- app.debug('position:' + JSON.stringify(position));
175
- app.debug('gpsFromBow:' + JSON.stringify(state.gpsFromBow));
176
- app.debug('gpsFromCenter:' + JSON.stringify(state.gpsFromCenter));
177
242
 
178
243
  let bow = position;
179
244
  if (state.gpsFromBow)
180
245
  bow = geolib.computeDestinationPoint(bow, state.gpsFromBow, headingTrue);
181
246
  if (state.gpsFromCenter)
182
247
  bow = geolib.computeDestinationPoint(bow, state.gpsFromCenter, headingTrue + 90);
183
- app.debug('bowPosition:' + JSON.stringify(bow));
184
248
  return bow;
185
249
  }
186
250
 
@@ -194,6 +258,32 @@ module.exports = (app) => {
194
258
  return result;
195
259
  }
196
260
 
261
+ function publishLineList() {
262
+ const lines = Array.isArray(state.options.lines) ? state.options.lines : [];
263
+
264
+ const payload = {
265
+ startLineName: state.startLineName ?? null,
266
+ lines: lines.map(l => ({
267
+ startLineName: l.startLineName,
268
+ startLineDescription: l.startLineDescription || null
269
+ }))
270
+ };
271
+
272
+ app.handleMessage('vessels.self', {
273
+ context: "vessels.self",
274
+ updates: [
275
+ {
276
+ values: [
277
+ {
278
+ path: 'navigation.racing.lines',
279
+ value: payload
280
+ }
281
+ ]
282
+ }
283
+ ]
284
+ });
285
+ }
286
+
197
287
  // Put an absolute position
198
288
  async function putStartLineEnd(end, position, callback, updateWaypoint) {
199
289
  app.debug(`putStartLineEnd: ${end} ${JSON.stringify(position)}`);
@@ -257,11 +347,12 @@ module.exports = (app) => {
257
347
  }
258
348
 
259
349
  // Set/create the waypoint if we are configured to do so
260
- if (state.options.updateStartLineWaypoint || updateWaypoint) {
350
+ const startLineOptions = getStartLineOptions();
351
+ if (startLineOptions.updateStartLineWaypoint || updateWaypoint) {
261
352
  try {
262
353
  const waypointConfig = `startLine${camelCase(end)}`;
263
354
  app.debug(`waypointConfig: ${waypointConfig}`);
264
- const waypointName = state.options[waypointConfig];
355
+ const waypointName = getStartLineOptions()[waypointConfig];
265
356
  app.debug(`waypointName: ${waypointName}`);
266
357
  let waypointId = startLine ? startLine[end + 'Id'] : null;
267
358
  app.debug(`waypointId: ${waypointId}`);
@@ -300,7 +391,7 @@ module.exports = (app) => {
300
391
 
301
392
  app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`);
302
393
 
303
- if (!waypointId && state.options.createStartLineWaypoint)
394
+ if (!waypointId && startLineOptions.createStartLineWaypoint)
304
395
  waypointId = uuidv4();
305
396
  if (waypointId)
306
397
  await app.resourcesApi.setResource('waypoints', waypointId, waypoint);
@@ -324,6 +415,24 @@ module.exports = (app) => {
324
415
  }
325
416
  }
326
417
 
418
+ async function setStartLineName(context, path, args, callback) {
419
+ try {
420
+ app.debug('setStartLineName:', JSON.stringify(args));
421
+ if (!args || args.startLineName != null && typeof args.startLineName !== 'string') {
422
+ return complete(callback, 400, 'Failed to set the startLine name!');
423
+ }
424
+ if (state.startLineName !== args.startLineName) {
425
+ state.startLineName = args.startLineName;
426
+ state.startLine = null;
427
+ findLineAndThenProcess(null, true);
428
+ }
429
+ publishLineList();
430
+ return complete(callback, 200, 'Put start line name: OK');
431
+ } catch (err) {
432
+ return complete(callback, 500, 'Put start line name: Failed ' + err);
433
+ }
434
+ }
435
+
327
436
  async function setStartLine(context, path, args, callback) {
328
437
  try {
329
438
  app.debug('setStartLine:', JSON.stringify(args));
@@ -356,7 +465,8 @@ module.exports = (app) => {
356
465
  const initialBearing = geolib.getRhumbLineBearing(otherEnd, thisEnd);
357
466
  const currentDistance = geolib.getDistance(otherEnd, thisEnd);
358
467
 
359
- const newDistance = args.delta && typeof args.delta === 'number' ? (currentDistance + args.delta) : currentDistance;
468
+ const delta = (args.delta && typeof args.delta === 'number' && end === 'stb') ? -args.delta : args.delta;
469
+ const newDistance = delta && typeof delta === 'number' ? (currentDistance + args.delta) : currentDistance;
360
470
  const newBearing = args.rotate && typeof args.rotate === 'number' ? (initialBearing + toDegrees(args.rotate)) : initialBearing;
361
471
 
362
472
  const newPosition = geolib.computeDestinationPoint(otherEnd, newDistance, newBearing);
@@ -365,7 +475,7 @@ module.exports = (app) => {
365
475
  }
366
476
 
367
477
  if (position)
368
- return putStartLineEnd(end, position, callback, args.updateWaypoint === true);
478
+ return putStartLineEnd(end, position, callback, getStartLineOptions().updateStartLineWaypoint);
369
479
  return complete(callback, 500, 'Failed to set the startLine: No position');
370
480
  } catch (err) {
371
481
  return complete(callback, 500, 'Failed to set the startLine: ' + err);
@@ -535,16 +645,18 @@ module.exports = (app) => {
535
645
  length: null,
536
646
  bearing: null,
537
647
  }
648
+ const startLineOptions = getStartLineOptions();
649
+ app.debug(`Start Line Options [${state.startLineName}]:`, JSON.stringify(startLineOptions));
538
650
  for (const [key, value] of Object.entries(data)) {
539
651
  app.debug(`WAYPOINT: ${key}, Value:`, JSON.stringify(value));
540
- if (value.name === state.options.startLinePort) {
652
+ if (value.name === startLineOptions.startLinePort) {
541
653
  let pos = waypointToPosition(value);
542
654
  if (pos) {
543
655
  startLine.portId = key;
544
656
  startLine.port = pos;
545
657
  }
546
658
  }
547
- if (value.name === state.options.startLineStb) {
659
+ if (value.name === startLineOptions.startLineStb) {
548
660
  let pos = waypointToPosition(value);
549
661
  if (pos) {
550
662
  startLine.stbId = key;
@@ -553,21 +665,31 @@ module.exports = (app) => {
553
665
  }
554
666
  }
555
667
 
668
+ let deltas;
556
669
  if (startLine.port && startLine.stb) {
557
670
  startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1);
558
671
  startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port);
559
672
 
560
673
  app.debug(`STARTLINE: startLinePort: ${JSON.stringify(startLine)}`);
561
674
  state.startLine = startLine;
675
+ state.vmgToLineNeg = [];
676
+ state.vmgToLinePos = [];
677
+ state.vmgToZoneStb = [];
678
+ state.vmgToZonePort = [];
679
+ deltas = [
680
+ {path: 'navigation.racing.startLinePort', value: startLine.port},
681
+ {path: 'navigation.racing.startLineStb', value: startLine.stb},
682
+ {path: 'navigation.racing.startLineLength', value: startLine.length},
683
+ ].filter(entry => entry.value !== null && entry.value !== undefined);
562
684
  } else {
563
685
  app.debug(`STARTLINE: undefined`);
564
686
  state.startLine = null;
687
+ deltas = [
688
+ {path: 'navigation.racing.startLinePort', value: null},
689
+ {path: 'navigation.racing.startLineStb', value: null},
690
+ {path: 'navigation.racing.startLineLength', value: null},
691
+ ];
565
692
  }
566
- const deltas = [
567
- {path: 'navigation.racing.startLinePort', value: startLine.port},
568
- {path: 'navigation.racing.startLineStb', value: startLine.stb},
569
- {path: 'navigation.racing.startLineLength', value: startLine.length},
570
- ].filter(entry => entry.value !== null && entry.value !== undefined);
571
693
  if (deltas.length > 0) {
572
694
  sendDeltas(deltas);
573
695
  processPosition(position);
@@ -616,52 +738,205 @@ module.exports = (app) => {
616
738
  const toPort = geolib.getPreciseDistance(bow, startLine.port, 0.1);
617
739
  const toStb = geolib.getPreciseDistance(bow, startLine.stb, 0.1);
618
740
 
741
+ // which is the closest end
619
742
  const closest = toPort < toStb ? startLine.port : startLine.stb;
620
743
  let toEnd;
621
- let ocs;
744
+ let bearingToEnd;
622
745
  let angle;
746
+ let closestEnd;
623
747
  if (closest === state.startLine.port) {
748
+ closestEnd = 'port';
624
749
  toEnd = toPort;
625
750
  app.debug('closest to Port(pin):' + toPort);
626
- const toBowBearing = geolib.getRhumbLineBearing(startLine.port, bow);
627
- app.debug('toBowBearing:' + toBowBearing);
628
- angle = toBowBearing - (startLine.bearing + 180) % 360;
751
+ bearingToEnd = geolib.getRhumbLineBearing(bow, startLine.port);
752
+ angle = bearingToEnd - startLine.bearing;
629
753
  } else {
754
+ closestEnd = 'stb';
630
755
  toEnd = toStb;
631
756
  app.debug('closest to Stb(boat):' + toStb);
632
- const toBowBearing = geolib.getRhumbLineBearing(startLine.stb, bow);
633
- app.debug('toBowBearing:' + toBowBearing);
634
- angle = startLine.bearing - toBowBearing;
757
+ bearingToEnd = geolib.getRhumbLineBearing(bow, startLine.stb);
758
+ angle = 180 - (bearingToEnd - startLine.bearing);
635
759
  }
636
760
  angle = ((angle + 180) % 360 + 360) % 360 - 180;
637
- app.debug('angle:' + angle);
638
- app.debug('toEnd:' + toEnd);
639
- ocs = angle < 0;
640
- app.debug('ocs:' + ocs);
641
-
642
- let absAngle = Math.abs(angle);
643
- const farFromLine = absAngle > 135;
644
- app.debug('farFromLine:' + farFromLine);
645
- const perpendicularToLine = toEnd * Math.sin(toRadians(absAngle));
646
- app.debug('perpendicularToLine:' + perpendicularToLine);
647
- let toLine = farFromLine ? Math.sqrt(toEnd * toEnd - perpendicularToLine * perpendicularToLine) : perpendicularToLine;
648
- toLine = Math.round(10 * toLine) / 10;
649
- app.debug('toLine:' + toLine);
761
+ const ocs = angle < 0;
762
+
763
+ // We define a start zone which includes a 45° wedge off each end of the line.
764
+ // - If the boat is inside the start zone, distanceToLine = perpendicular to line (or extension).
765
+ // - If the boat is outside the start zone, distanceToLine = distance parallel to the line to enter the zone +
766
+ // perpendicular distance to the line (or extension).
767
+ // This is illustrated for the starboard closest end of the line below:
768
+ // \ /
769
+ // \ /
770
+ // P---------------------------------B - - - - - -x-
771
+ // /135 PBz=135\' , VBx .
772
+ // / \ ' , .
773
+ // / \ ' ,.
774
+ // / b z - - - -V
775
+ // / \
776
+ //
777
+ // P=pin; B=boat; x=extension; b=perpToBoat; z=zoneEntry; V=vessel; N=north
778
+ // bearing = NBP
779
+ // PBz == 135
780
+ // bBz == zBx == 45
781
+ // Bb == bz == Vx == perpendicular distance to the line or extension.
782
+ // PBV = abs(angle)
783
+ // VBx = 180 - PBV
784
+ // Vx = toEnd * sin (VBx)
785
+ // bBV = 90 - VBx
786
+ // Vb = toEnd * sin (bBV)
787
+ // zb = sqrt((Vx * Vx) / 2)
788
+ // Vz = Vb - zb
789
+ const anglePBV = Math.abs(angle);
790
+ const inStartZone = anglePBV <= 135;
791
+ const angleVBx = 180 - anglePBV;
792
+ const perpToLineVx = toEnd * Math.sin(toRadians(angleVBx));
793
+ let toZoneVz = inStartZone ? 0 : ( toEnd * Math.sin(toRadians(90 - angleVBx)) - Math.sqrt(perpToLineVx * perpToLineVx / 2.0));
794
+ const toLine = Math.round( 10 * (toZoneVz + perpToLineVx)) / 10;
650
795
  const distanceToLine = ocs ? -toLine : toLine;
651
796
  app.debug('distanceToLine:' + distanceToLine);
652
797
  state.distanceToLine = distanceToLine;
798
+
799
+ // Collect VMG samples
800
+ const cog = app.getSelfPath("navigation.courseOverGroundTrue")?.value;
801
+ const sog = app.getSelfPath("navigation.speedOverGround")?.value;
802
+ if (cog != null && sog != null) {
803
+ collectVmgSamples(cog, sog, startLine.bearing, toZoneVz, perpToLineVx);
804
+ }
805
+ const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd);
806
+ const ttb = !ocs && state.timerRunning && state.timeToStart > 0 ? Math.max(0, state.timeToStart - ttl) : null;
807
+
653
808
  sendDeltas([
654
809
  {path: 'navigation.racing.distanceStartline', value: distanceToLine},
810
+ {path: "navigation.racing.timeToLine", value: ttl},
811
+ {path: "navigation.racing.timeToBurn", value: ttb}
655
812
  ]);
813
+
656
814
  } else if (state.distanceToLine) {
657
815
  state.distanceToLine = null;
658
816
  sendDeltas([
659
817
  {path: 'navigation.racing.distanceStartline', value: null},
660
818
  {path: 'navigation.racing.startLineBias', value: null},
819
+ {path: 'navigation.racing.timeToLine', value: null},
820
+ {path: 'navigation.racing.timeToBurn', value: null},
661
821
  ]);
662
822
  }
663
823
  }
664
824
 
825
+ function collectVmgSamples(cog, sog, lineBearing, toZoneVz, perpToLineVx) {
826
+ try {
827
+ // TODO configure these rejection values
828
+ if (sog < 1.0) return; // ignore drifting / tacking stalls
829
+ if (toZoneVz > 1000 || perpToLineVx > 1000) return; // to far away from the line
830
+
831
+ // Compute angle boatDir-relative to line bearing
832
+ let angleRad = cog - toRadians(lineBearing);
833
+
834
+ // VMG components
835
+ const vmgNormal = sog * Math.sin(angleRad); // +ve = towards line, -ve = away
836
+ const vmgTangent = sog * Math.cos(angleRad); // +ve = towards pin, -ve = towards stb
837
+
838
+ // Insert vmg arrays
839
+ insertSample(state.vmgToLinePos, vmgNormal);
840
+ insertSample(state.vmgToLineNeg, -vmgNormal);
841
+ insertSample(state.vmgToZonePort, vmgTangent);
842
+ insertSample(state.vmgToZoneStb, -vmgTangent);
843
+ } catch (err) {
844
+ app.error(err);
845
+ }
846
+ }
847
+
848
+ function insertSample(arr, value) {
849
+ if (value <= 1.0) return; // reject low VMGs
850
+ if (arr.length >= 600) arr.shift(); // Keep up to 10 minutes at 1Hz
851
+
852
+ let lo = 0, hi = arr.length;
853
+ while (lo < hi) {
854
+ const mid = (lo + hi) >> 1;
855
+ if (arr[mid] < value) lo = mid + 1;
856
+ else hi = mid;
857
+ }
858
+ arr.splice(lo, 0, value);
859
+ }
860
+
861
+ function computeTimeToLine(cog, sog, lineBearing, toZoneVz, perpToLineVx, ocs, closestEnd) {
862
+
863
+ let vmgNormalSigned = 0;
864
+ let vmgTangentSigned = 0;
865
+
866
+ if (cog != null && sog != null) {
867
+ const lineBearingRad = toRadians(lineBearing);
868
+ if (lineBearingRad === null) {
869
+ return 0;
870
+ }
871
+
872
+ // Angle between boat COG and line bearing
873
+ const angleRad = cog - lineBearingRad;
874
+
875
+ // Signed VMG components from *current* COG/SOG
876
+ vmgNormalSigned = sog * Math.sin(angleRad); // normal to line
877
+ vmgTangentSigned = sog * Math.cos(angleRad); // along line (stb->port is +)
878
+ }
879
+
880
+ // 1. Effective VMG normal (perpendicular to line)
881
+ //
882
+ // History:
883
+ // - if OCS, use vmgToLineNeg (away from line => back towards line in this case)
884
+ // - otherwise, use vmgToLinePos
885
+ const histNormal = percentileFromSorted(ocs ? state.vmgToLineNeg : state.vmgToLinePos, 0.9);
886
+
887
+ // Instantaneous VMG in the *required* direction
888
+ // if we are OCS, then the required direction is the opposite of the effective direction
889
+ let vmgInstNormal = ocs ? -vmgNormalSigned : vmgNormalSigned;
890
+ const vmgEffNormal = Math.max(histNormal || 0, vmgInstNormal || 0);
891
+
892
+ // 2. Effective VMG along the line (towards the chosen zone entry)
893
+ let vmgHistParallel = 0;
894
+ let vmgInstParallel = 0;
895
+
896
+ if (toZoneVz > 0) {
897
+ if (closestEnd === 'port') {
898
+ // Coming from the pin end, we will sail from PORT towards STB
899
+ // => use STB-direction samples.
900
+ vmgHistParallel = percentileFromSorted(state.vmgToZoneStb, 0.9);
901
+ if (vmgTangentSigned < 0) {
902
+ vmgInstParallel = -vmgTangentSigned; // towards stb
903
+ }
904
+ } else {
905
+ // Coming from the boat end, we will sail from STB towards PORT
906
+ // => use PORT-direction samples.
907
+ vmgHistParallel = percentileFromSorted(state.vmgToZonePort, 0.9);
908
+ if (vmgTangentSigned > 0) {
909
+ vmgInstParallel = vmgTangentSigned; // towards port
910
+ }
911
+ }
912
+ }
913
+
914
+ const vmgEffectParallel = Math.max(vmgHistParallel || 0, vmgInstParallel || 0);
915
+
916
+ // 3. Combine legs: along to zone, then perpendicular to line
917
+ let ttl = 0;
918
+
919
+ // Outside the start zone: first go along the line (or zone boundary)
920
+ if (toZoneVz > 0 && vmgEffectParallel > 0) {
921
+ ttl += toZoneVz / vmgEffectParallel;
922
+ }
923
+
924
+ // Then go perpendicular to the line to actually hit it
925
+ if (perpToLineVx > 0 && vmgEffNormal > 0) {
926
+ ttl += perpToLineVx / vmgEffNormal;
927
+ }
928
+
929
+ app.debug(`computeTimeToLine: ${closestEnd} ${toZoneVz} ${perpToLineVx} ${vmgEffectParallel} ${vmgEffNormal} = ${ttl}`);
930
+ // If we still somehow have zero (no speed or distances), just return time to start so TTB logic doesn't explode.
931
+ return ttl <= 0 ? state.timeToStart : ttl;
932
+ }
933
+
934
+ function percentileFromSorted(arr, p) {
935
+ if (!arr || !arr.length) return 0;
936
+ const idx = Math.floor(p * (arr.length - 1));
937
+ return arr[idx];
938
+ }
939
+
665
940
  function processWind(twd) {
666
941
  if (!twd) {
667
942
  twd = app.getSelfPath('environment.wind.directionTrue');
@@ -689,7 +964,7 @@ module.exports = (app) => {
689
964
  sendDeltas([
690
965
  {path: 'navigation.racing.stbLineBias', value: bias},
691
966
  {path: 'navigation.racing.nextLegTrueWindAngle', value: toRadians(nextTwa)},
692
- ]);
967
+ ].filter(entry => entry.value !== null && entry.value !== undefined));
693
968
  }
694
969
 
695
970
  function processRoute(activeRoute) {
@@ -762,12 +1037,24 @@ module.exports = (app) => {
762
1037
  processPosition(null);
763
1038
  }
764
1039
  }
1040
+
1041
+ function getStartLineOptions() {
1042
+ app.debug("getStartLineOptions for ", state.startLineName);
1043
+ for (const lineOption of state.options.lines) {
1044
+ if (lineOption.startLineName === state.startLineName) {
1045
+ app.debug("lineOption: ", JSON.stringify(lineOption));
1046
+ return lineOption;
1047
+ }
1048
+ }
1049
+ app.debug("lineOption: ", JSON.stringify(state.options));
1050
+ return state.options;
1051
+ }
765
1052
 
766
1053
  function getStartLine() {
767
1054
  const stbEnd = app.getSelfPath(`navigation.racing.startLineStb`);
768
1055
  const portEnd = app.getSelfPath(`navigation.racing.startLinePort`);
769
1056
  if (stbEnd && stbEnd.value && portEnd && portEnd.value) {
770
- startLine = { stb: stbEnd.value, port: portEnd.value};
1057
+ let startLine = { stb: stbEnd.value, port: portEnd.value};
771
1058
  startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1);
772
1059
  startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port);
773
1060
  state.startLine = startLine;
@@ -780,7 +1067,7 @@ module.exports = (app) => {
780
1067
  // Return the plugin
781
1068
  return {
782
1069
  id: 'signalk-racer',
783
- name: 'SignalK Racing Plugin',
1070
+ name: 'SignalK Racer',
784
1071
  start: (options) => {
785
1072
  if (!options.startLinePort || !options.startLineStb) {
786
1073
  app.error('Missing waypoint names: startLinePort and/or startLineStb not configured.');
@@ -798,6 +1085,7 @@ module.exports = (app) => {
798
1085
  state.gpsFromCenter = fromCenter ? fromCenter.value : null;
799
1086
  state.timeToStart = options.timer;
800
1087
  state.startLine = getStartLine();
1088
+ publishLineList();
801
1089
 
802
1090
  // Is the timer already started
803
1091
  {
@@ -968,7 +1256,7 @@ module.exports = (app) => {
968
1256
  });
969
1257
  break;
970
1258
  case 'navigation.racing.startLineStb' :
971
- putStartLineEnd('port', v.value, ignored => {
1259
+ putStartLineEnd('stb', v.value, ignored => {
972
1260
  }, false).then(ignored => {
973
1261
  });
974
1262
  break;
@@ -985,6 +1273,7 @@ module.exports = (app) => {
985
1273
  app.registerPutHandler('vessels.self', 'navigation.racing.startLineStb', (ctx, path, value, callback) => putStartLineEnd('stb', value, callback));
986
1274
 
987
1275
  // register to API paths for this plugin
1276
+ app.registerPutHandler('vessels.self', 'navigation.racing.setStartLineName', setStartLineName);
988
1277
  app.registerPutHandler('vessels.self', 'navigation.racing.setStartLine', setStartLine);
989
1278
  app.registerPutHandler('vessels.self', 'navigation.racing.setStartTime', startTimeCommand);
990
1279
  },
package/openapi.json CHANGED
@@ -69,6 +69,80 @@
69
69
  }
70
70
  }
71
71
  }
72
+ },
73
+
74
+ "/signalk/v1/api/vessels/self/navigation/racing/lines": {
75
+ "get": {
76
+ "summary": "List available named start lines",
77
+ "description": "Returns the currently active start line and a list of configured line names with optional descriptions.",
78
+ "responses": {
79
+ "200": {
80
+ "description": "List of line names",
81
+ "content": {
82
+ "application/json": {
83
+ "schema": {
84
+ "type": "object",
85
+ "properties": {
86
+ "startLineName": {
87
+ "type": "string",
88
+ "nullable": true,
89
+ "description": "Currently selected line name, or null for default"
90
+ },
91
+ "lines": {
92
+ "type": "array",
93
+ "items": {
94
+ "type": "object",
95
+ "properties": {
96
+ "startLineName": { "type": "string" },
97
+ "startLineDescription": {
98
+ "type": "string",
99
+ "nullable": true
100
+ }
101
+ },
102
+ "required": ["startLineName"]
103
+ }
104
+ }
105
+ },
106
+ "required": ["startLineName", "lines"]
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ },
114
+
115
+ "/signalk/v1/api/vessels/self/navigation/racing/setStartLineName": {
116
+ "put": {
117
+ "summary": "Select a named start line to use",
118
+ "description": "Set the active start line by name, or set null to revert to the default line.",
119
+ "requestBody": {
120
+ "required": true,
121
+ "content": {
122
+ "application/json": {
123
+ "schema": {
124
+ "type": "object",
125
+ "properties": {
126
+ "startLineName": {
127
+ "type": "string",
128
+ "nullable": true,
129
+ "description": "Line name to activate, or null to use the default"
130
+ }
131
+ },
132
+ "required": ["startLineName"]
133
+ }
134
+ }
135
+ }
136
+ },
137
+ "responses": {
138
+ "200": {
139
+ "description": "Active line updated"
140
+ },
141
+ "400": {
142
+ "description": "Invalid line name (not found)"
143
+ }
144
+ }
145
+ }
72
146
  }
73
147
  }
74
148
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "signalk-racer",
3
- "version": "1.0.0",
4
- "description": "Signalk plugin to calculate values of interest to sail racers: such as Time to Start, Time of Start, Distance to Line; Next leg TWA.",
3
+ "version": "1.1.0",
4
+ "description": "Signalk plugin to calculate values of interest to sail racers, such as: Time to Start; Time of Start, Time to Burn; Distance to Line; Next leg TWA.",
5
5
  "main": "index.js",
6
6
  "repository": {
7
7
  "type": "git",
package/public/index.html CHANGED
@@ -10,8 +10,9 @@
10
10
  color: white; /* optional for visibility */
11
11
  }
12
12
 
13
- .button-timer-command { background-color: #041b8e; color: white; font-size: 1em; font-weight: bold; }
14
- .button-timer-adjust { background-color: #2196F3; color: white; }
13
+ .button-timer-command { background-color: #041b8e; color: white; font-size: 1em; font-weight: bold; }
14
+ .button-timer-adjust { background-color: #2196F3; color: white; }
15
+
15
16
  button {
16
17
  padding: 1em 1em;
17
18
  font-size: 1em;
@@ -26,16 +27,13 @@
26
27
  margin: 1em auto;
27
28
  text-align: center;
28
29
  }
29
- button {
30
- padding: 1em 1em;
31
- font-size: 1em;
32
- cursor: pointer;
33
- }
30
+
34
31
  input[readonly] {
35
32
  background: #eee;
36
33
  border: 1px solid #ccc;
37
34
  padding: 0.3em;
38
35
  }
36
+
39
37
  #status {
40
38
  margin-top: 1em;
41
39
  font-size: 1em;
@@ -49,6 +47,12 @@
49
47
  .stb-move { background-color: #080; color: white;}
50
48
  .stb-rotate { background-color: #080; color: white; }
51
49
 
50
+ #startLineSelect {
51
+ font-size: 1em;
52
+ padding: 0.3em;
53
+ margin-left: 0.5em;
54
+ }
55
+
52
56
  </style>
53
57
  </head>
54
58
  <body>
@@ -57,8 +61,10 @@
57
61
  let socket;
58
62
  let statusTimer;
59
63
  let startTime;
64
+
60
65
  function updateStatus(message) {
61
66
  const status = document.getElementById('status');
67
+ if (!status) return;
62
68
  status.textContent = message;
63
69
  if (statusTimer) clearTimeout(statusTimer);
64
70
  statusTimer = setTimeout(() => {
@@ -103,10 +109,10 @@
103
109
  if (update.values) {
104
110
  update.values.forEach(value => {
105
111
  switch (value.path) {
106
- case "navigation.racing.startLineLength" :
112
+ case "navigation.racing.startLineLength":
107
113
  // document.getElementById("lineLength").value = value.value.toFixed(1);
108
114
  break;
109
- case "navigation.racing.stbLineBias" :
115
+ case "navigation.racing.stbLineBias":
110
116
  // document.getElementById("lineBias").value = value.value.toFixed(1);
111
117
  break;
112
118
  case "navigation.racing.timeToStart":
@@ -172,7 +178,98 @@
172
178
  updateStatus(`Sent ${delta}m ${rotate}° request for ${end}`);
173
179
  }
174
180
 
181
+ function loadStartLines() {
182
+ const url = window.location.origin +
183
+ '/signalk/v1/api/vessels/self/navigation/racing/lines';
184
+
185
+ fetch(url)
186
+ .then(res => {
187
+ if (!res.ok) {
188
+ throw new Error('HTTP ' + res.status);
189
+ }
190
+ return res.json();
191
+ })
192
+ .then(data => {
193
+ const select = document.getElementById('startLineSelect');
194
+ if (!select) return;
195
+
196
+ // Clear existing options
197
+ select.innerHTML = '';
198
+
199
+ // Default option = "no named line"
200
+ const defaultOpt = document.createElement('option');
201
+ defaultOpt.value = '';
202
+ defaultOpt.textContent = 'Default line';
203
+ select.appendChild(defaultOpt);
204
+
205
+ const payload = data && data.value ? data.value : { lines: [], startLineName: null };
206
+ if (Array.isArray(payload.lines)) {
207
+ payload.lines.forEach(line => {
208
+ const opt = document.createElement('option');
209
+ opt.value = line.startLineName;
210
+ opt.textContent = line.startLineDescription
211
+ ? `${line.startLineName} – ${line.startLineDescription}`
212
+ : line.startLineName;
213
+ select.appendChild(opt);
214
+ });
215
+ }
216
+
217
+ // Preselect active
218
+ if (!payload.startLineName) {
219
+ select.value = '';
220
+ } else {
221
+ select.value = payload.startLineName;
222
+ if (select.value !== payload.startLineName) {
223
+ // In case active name not in the list
224
+ select.value = '';
225
+ }
226
+ }
227
+
228
+ updateStatus('Loaded start lines: ' + payload.lines.length + ' lines');
229
+ })
230
+ .catch(err => {
231
+ console.error('Failed to load start lines:', err);
232
+ updateStatus('Failed to load start lines: ' + err.message);
233
+ });
234
+ }
235
+
236
+ function setStartLineName(startLineName) {
237
+ const url = window.location.origin +
238
+ '/signalk/v1/api/vessels/self/navigation/racing/setStartLineName';
239
+
240
+ const body = {
241
+ value: {
242
+ startLineName: startLineName === '' ? null : startLineName
243
+ }
244
+ };
245
+
246
+ fetch(url, {
247
+ method: 'PUT',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify(body)
250
+ })
251
+ .then(res => {
252
+ if (!res.ok) {
253
+ throw new Error('HTTP ' + res.status);
254
+ }
255
+ return res.json().catch(() => ({}));
256
+ })
257
+ .then(() => {
258
+ updateStatus(
259
+ startLineName
260
+ ? `Active line set to: ${startLineName}`
261
+ : 'Active line set to Default'
262
+ );
263
+ })
264
+ .catch(err => {
265
+ console.error('Failed to set active line:', err);
266
+ updateStatus('Failed to set active line: ' + err.message);
267
+ });
268
+ }
269
+
270
+ // connect WS and load lines once the page is running
175
271
  connectWebSocket();
272
+ loadStartLines();
176
273
 
177
274
  function formatTimeToMMSS(seconds) {
178
275
  const mm = Math.floor(seconds / 60);
@@ -225,7 +322,7 @@
225
322
  }
226
323
  }
227
324
  }));
228
- updateStatus(`Sent start time: ${startTime}`);
325
+ updateStatus(`Sent start time: ${date.toLocaleTimeString()}`);
229
326
  } catch (err) {
230
327
  console.log('Could not set start at ', err);
231
328
  }
@@ -278,10 +375,11 @@
278
375
  </div>
279
376
  </div>
280
377
 
281
- <!-- Timer Display -->
282
- <!-- div style="margin-top: 2em;">
283
- <div id="timeToStart" style="font-size: 4em; font-weight: bold;">00:00</div>
284
- </div -->
378
+ <!-- Start-line selector -->
379
+ <div style="margin-top: 1em;">
380
+ <label for="startLineSelect">Start line:</label>
381
+ <select id="startLineSelect" onchange="setStartLineName(this.value)"></select>
382
+ </div>
285
383
 
286
384
  <!-- Start Time Row -->
287
385
  <div style="margin-top: 1em; display: flex; justify-content: center; align-items: center; gap: 0.5em;">
package/racer-line.png ADDED
Binary file