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 +34 -4
- package/ReleaseNotes.md +9 -0
- package/index.js +337 -48
- package/openapi.json +74 -0
- package/package.json +2 -2
- package/public/index.html +112 -14
- package/racer-line.png +0 -0
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
|
-
|
|
72
|
+

|
|
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
|
|
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: '
|
|
18
|
+
title: 'Start line starboard-end (boat) waypoint name',
|
|
11
19
|
default: 'startBoat'
|
|
12
20
|
},
|
|
13
21
|
startLinePort: {
|
|
14
22
|
type: 'string',
|
|
15
|
-
title: '
|
|
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:
|
|
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:
|
|
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
|
-
|
|
214
|
+
if (rad === null || rad === undefined) return null;
|
|
215
|
+
return rad * (180 / Math.PI);
|
|
148
216
|
}
|
|
149
217
|
|
|
150
218
|
function toRadians(degrees) {
|
|
151
|
-
|
|
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
|
-
|
|
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 =
|
|
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 &&
|
|
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
|
|
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,
|
|
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 ===
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
627
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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('
|
|
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.
|
|
4
|
-
"description": "Signalk plugin to calculate values of interest to sail racers
|
|
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
|
|
14
|
-
.button-timer-adjust
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
<!--
|
|
282
|
-
|
|
283
|
-
<
|
|
284
|
-
|
|
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
|