signalk-racer 1.3.0 → 1.3.1
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 +14 -34
- package/ReleaseNotes.md +29 -1
- package/index.js +61 -46
- package/package.json +1 -1
- package/public/index.html +32 -206
- package/public/line-view.js +430 -0
- package/racer.js +115 -75
- package/tests/racer-ttl.test.js +8 -3
- package/tests/racer-vmg.test.js +12 -64
- package/tests/racer-zone.test.js +184 -0
package/README.md
CHANGED
|
@@ -25,7 +25,8 @@ This plugin calculates and publishes the following Signal K paths:
|
|
|
25
25
|
| `navigation.racing.bestVmg.toPortEnd` | Best VMG along the line towards the port end (pin) | `m/s` | |
|
|
26
26
|
| `navigation.racing.bestVmg.toStbEnd` | Best VMG along the line towards the stb end (boat) | `m/s` | |
|
|
27
27
|
| `navigation.racing.bestVmg.*.override` | The manual adjustment behind each best VMG, `null` when not adjusted | `m/s` | |
|
|
28
|
-
| `navigation.racing.
|
|
28
|
+
| `navigation.racing.effectiveVmg.toLine` | VMG the perpendicular leg of the time to line is divided by | `m/s` | |
|
|
29
|
+
| `navigation.racing.effectiveVmg.alongLine` | VMG the along-line leg is divided by, 0 when inside the start zone | `m/s` | |
|
|
29
30
|
| `navigation.racing.startLinePort` | Location of the port (pin) end of the start line | `{latitude,longitude}` | Y |
|
|
30
31
|
| `navigation.racing.startLineStb` | Location of the starboard (boat) end of the start line | `{latitude,longitude}` | Y |
|
|
31
32
|
| `navigation.racing.nextLegHeading` | True heading for the next leg of the course | `rad` | |
|
|
@@ -60,6 +61,7 @@ You can configure the plugin via the Signal K web interface or by editing `setti
|
|
|
60
61
|
| `period` | How often to update values (in milliseconds) | `1000` |
|
|
61
62
|
| `updateStartLineWaypoint` | Should the waypoints be updated if the line is set/adjusted | `true` |
|
|
62
63
|
| `createStartLineWaypoint` | Should the waypoints be created if the line is set | `true` |
|
|
64
|
+
| `minEffectiveVmg` | Floor under each effective VMG used for time to line (in knots) | `0.5` |
|
|
63
65
|
| `lines` | Array of named lines | null |
|
|
64
66
|
|
|
65
67
|
---
|
|
@@ -97,18 +99,13 @@ If `navigation.headingTrue` is not available, then `navigation.courseOverGroundT
|
|
|
97
99
|
|
|
98
100
|
- 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).
|
|
99
101
|
This allows a boat to luff / delay without changing the time to line.
|
|
102
|
+
- Each effective VMG is floored at `minEffectiveVmg` (default 0.5 knots), so a direction
|
|
103
|
+
with nothing collected yet - or a drifting boat - still yields a time rather than none.
|
|
104
|
+
The floor is not applied to the along-line leg when the boat is inside the start zone,
|
|
105
|
+
there being no such leg to sail.
|
|
100
106
|
- 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.
|
|
101
107
|
- 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.
|
|
102
108
|
- If the line is changed, then the samples used to calculate the effective VMGs are cleared.
|
|
103
|
-
- Every sample keeps the `cog` and `sog` that produced it, so the course behind the 90th
|
|
104
|
-
percentile VMG towards the line can be recovered and is published as
|
|
105
|
-
`navigation.racing.bestApproach`. The direction is chosen exactly as the time to line
|
|
106
|
-
chooses its legs, and is reported alongside the course:
|
|
107
|
-
- Inside the start zone the line is closed across it, so `toCourseSide` is used, or
|
|
108
|
-
`fromCourseSide` when OCS.
|
|
109
|
-
- Outside the start zone the boat must first run along the line, so the along-line
|
|
110
|
-
samples are used, in the direction *away* from the closest end: beyond the pin it
|
|
111
|
-
must travel towards the starboard end, so `toStbEnd` governs, and vice versa.
|
|
112
109
|
- The four collected VMGs are published under `navigation.racing.bestVmg.*` and may be manually
|
|
113
110
|
adjusted. An adjustment stands in for the 90th percentile of the samples, but the VMG the boat
|
|
114
111
|
is actually sailing still wins if it is better, so an adjustment can never make the estimate
|
|
@@ -145,11 +142,11 @@ line and the boat, so it zooms as the boat closes. The line length and the headi
|
|
|
145
142
|
the line are marked on the line itself, and the boat is drawn as a triangle pointing along its
|
|
146
143
|
heading, amber when OCS.
|
|
147
144
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
A projection runs from the boat, drawn to the same scale as the rest of the drawing, so its **tip
|
|
146
|
+
can be read directly against the line**. Hovering the boat reports its SOG and COG and names the
|
|
147
|
+
line running from it.
|
|
151
148
|
|
|
152
|
-
####
|
|
149
|
+
#### Course projection — "Current cog/sog to start"
|
|
153
150
|
|
|
154
151
|
Runs along the boat's present `navigation.courseOverGroundTrue`. While the start timer is counting
|
|
155
152
|
down, its length is `SOG × timeToStart`: the distance the boat will cover before the gun if nothing
|
|
@@ -162,24 +159,6 @@ changes. Its tip is therefore **where the boat will be when the start fires**:
|
|
|
162
159
|
With no timer running there is nothing to project against, so it degrades to a short fixed-length
|
|
163
160
|
stub showing course only.
|
|
164
161
|
|
|
165
|
-
#### Thin faint line — "Best VMG to start"
|
|
166
|
-
|
|
167
|
-
Runs along `navigation.racing.bestApproach`, with its length that course's own
|
|
168
|
-
`SOG × timeToStart`. This is **not** a synthetic best case: it is a course the boat has genuinely
|
|
169
|
-
sailed in the last few minutes, being the `cog`/`sog` recorded against the 90th percentile VMG
|
|
170
|
-
sample for the direction that actually matters (across the line, or along it when outside the start
|
|
171
|
-
zone — see [Time To Line](#time-to-line-navigationracingtimetoline)). Because it is a real point of
|
|
172
|
-
sail it runs on its **own bearing**, not the current one, so it will diverge from the thick line
|
|
173
|
-
whenever the boat is not sailing as well as it recently has.
|
|
174
|
-
|
|
175
|
-
Comparing the two tips is the point of the pair: if the thin line reaches the line and the thick one
|
|
176
|
-
falls short, then sailing the boat as well as it has already been sailed would get you there, and
|
|
177
|
-
the difference between the tips is what is being left on the table. If no samples have been
|
|
178
|
-
collected yet, or the timer is not running, this line is not drawn.
|
|
179
|
-
|
|
180
|
-
Over a long countdown both projections run well off the drawing and are simply clipped at its edge;
|
|
181
|
-
the part that matters — where they cross the line — stays visible.
|
|
182
|
-
|
|
183
162
|
---
|
|
184
163
|
|
|
185
164
|
## 🌐 API Access
|
|
@@ -226,14 +205,15 @@ Used to **adjust** or **reset** the best VMGs used to estimate the time to line.
|
|
|
226
205
|
"vmg": "toCourseSide" | "fromCourseSide" | "toPortEnd" | "toStbEnd",
|
|
227
206
|
"value": 5.0,
|
|
228
207
|
"delta": 0.0514,
|
|
229
|
-
"command": "reset"
|
|
208
|
+
"command": "reset" | "clear"
|
|
230
209
|
}
|
|
231
210
|
```
|
|
232
211
|
|
|
233
212
|
- `vmg`: which best VMG to adjust. Optional for `reset`, which otherwise clears all four.
|
|
234
213
|
- `value`: absolute best VMG in `m/s`.
|
|
235
214
|
- `delta`: adjustment in `m/s` applied to the current best VMG.
|
|
236
|
-
- `command`: `"reset"` to clear the override and revert to the collected samples
|
|
215
|
+
- `command`: `"reset"` to clear the override and revert to the collected samples, or
|
|
216
|
+
`"clear"` to throw away the collected samples themselves and start again.
|
|
237
217
|
|
|
238
218
|
An absolute value may also be put directly to `navigation.racing.bestVmg.<name>`.
|
|
239
219
|
|
package/ReleaseNotes.md
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
-
## 1.
|
|
1
|
+
## 1.3.1 Start line approach
|
|
2
|
+
+ Fixed the distance to the start zone being measured against a 54.7 degree wedge while
|
|
3
|
+
the zone itself was tested at 45 degrees, so `distanceStartline` and `timeToLine`
|
|
4
|
+
jumped by 0.29 times the offset as a boat crossed the boundary. They are now continuous
|
|
5
|
+
and the along-line leg reaches zero exactly at the wedge
|
|
6
|
+
+ The start zone geometry moved from `index.js` into `racer.js` as `startZoneDistances`,
|
|
7
|
+
where it is unit tested - it had no coverage at all, which is how the above survived
|
|
8
|
+
+ `navigation.racing.timeToBurn` is now published when OCS as well. The time to line is
|
|
9
|
+
then the time to get back over the line from the course side, so the spare time before
|
|
10
|
+
you have to turn and do it is just as real, and goes negative once it is too late
|
|
11
|
+
+ Removed `navigation.racing.bestApproach` and the `cog`/`sog` each VMG sample carried
|
|
12
|
+
to produce it. It described one course the boat had genuinely sailed, which was useful
|
|
13
|
+
but not the path the time to line is computed over, so it read as a second answer to a
|
|
14
|
+
question that already had one. Best VMG angles will be revisited once the polar plugin
|
|
15
|
+
offers an API for them, by which point the shape is likely to be quite different
|
|
16
|
+
+ Each effective VMG is now floored at a configurable minimum, `minEffectiveVmg`,
|
|
17
|
+
defaulting to 0.5 knots. A direction with nothing collected reported no VMG at all, which
|
|
18
|
+
left the time to line falling back to the time to start rather than estimating anything
|
|
19
|
+
+ `navigation.racing.setBestVmg` accepts `command: "clear"`, which discards the collected
|
|
20
|
+
samples themselves rather than just the manual adjustments
|
|
21
|
+
+ Published the two VMGs the time to line is actually divided by as
|
|
22
|
+
`navigation.racing.effectiveVmg.{toLine,alongLine}`: the collected best for each
|
|
23
|
+
direction, or the VMG being sailed right now when that is better. `bestVmg.*` reports
|
|
24
|
+
only the collected half, so a client drawing the approach from it disagreed with the
|
|
25
|
+
published time whenever the boat was going well
|
|
26
|
+
|
|
27
|
+
## 1.3.0 Visualize line and best VMGs
|
|
2
28
|
+ Added `navigation.racing.swapStartLine` to swap the port and starboard ends of the line,
|
|
3
29
|
carrying the collected VMG samples across with the ends
|
|
4
30
|
+ Published the four best VMGs behind the time to line as `navigation.racing.bestVmg.`
|
|
@@ -18,6 +44,8 @@
|
|
|
18
44
|
+ Fixed the start line bias not being cleared when the line is lost
|
|
19
45
|
+ Fixed the webapp race countdown never being displayed
|
|
20
46
|
|
|
47
|
+
## 1.2.0 Never released
|
|
48
|
+
|
|
21
49
|
## 1.1.2 TTL fixes
|
|
22
50
|
+ use a FIFO queue for VMGs towards the line.
|
|
23
51
|
+ avoid undefined values in deltas
|
package/index.js
CHANGED
|
@@ -5,7 +5,7 @@ module.exports = (app) => {
|
|
|
5
5
|
// Last published best VMGs, so we only send deltas when they actually change.
|
|
6
6
|
publishedBestVmg: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null},
|
|
7
7
|
publishedBestVmgOverrides: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null},
|
|
8
|
-
|
|
8
|
+
publishedEffectiveVmg: {toLine: null, alongLine: null},
|
|
9
9
|
};
|
|
10
10
|
const geolib = require('geolib')
|
|
11
11
|
const racerSchema = {
|
|
@@ -62,6 +62,11 @@ module.exports = (app) => {
|
|
|
62
62
|
title: 'Minimum VMG component in m/s for a sample to be collected in that direction',
|
|
63
63
|
default: 0.01
|
|
64
64
|
},
|
|
65
|
+
minEffectiveVmg: {
|
|
66
|
+
type: 'number',
|
|
67
|
+
title: 'Minimum effective VMG in knots used to estimate time to line',
|
|
68
|
+
default: 0.5
|
|
69
|
+
},
|
|
65
70
|
maxDistance: {
|
|
66
71
|
type: 'number',
|
|
67
72
|
title: 'Maximum distance to line and/or line zone in meters to consider for VMG calculations',
|
|
@@ -116,13 +121,14 @@ module.exports = (app) => {
|
|
|
116
121
|
collectVmgSamples,
|
|
117
122
|
resetVmgSamples,
|
|
118
123
|
computeTimeToLine,
|
|
124
|
+
startZoneDistances,
|
|
125
|
+
effectiveVmg,
|
|
119
126
|
vmgNames,
|
|
120
127
|
getAllBestVmg,
|
|
121
128
|
getBestVmgOverrides,
|
|
122
129
|
setBestVmg,
|
|
123
130
|
clearBestVmgOverrides,
|
|
124
131
|
swapVmgEnds,
|
|
125
|
-
getBestApproach
|
|
126
132
|
} = require('./racer');
|
|
127
133
|
|
|
128
134
|
const unsubscribes = [];
|
|
@@ -155,12 +161,23 @@ module.exports = (app) => {
|
|
|
155
161
|
}
|
|
156
162
|
},
|
|
157
163
|
{
|
|
158
|
-
"path": "navigation.racing.
|
|
164
|
+
"path": "navigation.racing.effectiveVmg.toLine",
|
|
159
165
|
"value": {
|
|
160
|
-
"type": "
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
166
|
+
"type": "number",
|
|
167
|
+
"units": "m/s",
|
|
168
|
+
"description": "VMG the perpendicular leg of the time to line was divided by: the collected best across the line, or the VMG being sailed right now if that is better",
|
|
169
|
+
"displayName": "Effective VMG to line",
|
|
170
|
+
"shortName": "VMG>Line"
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"path": "navigation.racing.effectiveVmg.alongLine",
|
|
175
|
+
"value": {
|
|
176
|
+
"type": "number",
|
|
177
|
+
"units": "m/s",
|
|
178
|
+
"description": "VMG the along-line leg of the time to line was divided by, zero when inside the start zone",
|
|
179
|
+
"displayName": "Effective VMG along line",
|
|
180
|
+
"shortName": "VMG>Zone"
|
|
164
181
|
}
|
|
165
182
|
},
|
|
166
183
|
{
|
|
@@ -322,9 +339,6 @@ module.exports = (app) => {
|
|
|
322
339
|
function publishBestVmg(position = {}) {
|
|
323
340
|
const best = state.startLine ? getAllBestVmg() : null;
|
|
324
341
|
const overrides = state.startLine ? getBestVmgOverrides() : null;
|
|
325
|
-
const approach = state.startLine
|
|
326
|
-
? getBestApproach(position.ocs, position.toZoneVz, position.closestEnd)
|
|
327
|
-
: null;
|
|
328
342
|
const deltas = [];
|
|
329
343
|
|
|
330
344
|
for (const name of Object.keys(vmgNames)) {
|
|
@@ -344,17 +358,20 @@ module.exports = (app) => {
|
|
|
344
358
|
}
|
|
345
359
|
}
|
|
346
360
|
|
|
347
|
-
// The
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
// The two VMGs the time to line was actually divided by. These are what a client
|
|
362
|
+
// needs to draw the legs the estimate was built from; the collected bests above
|
|
363
|
+
// are only half the story, since the boat sailing better than its history right
|
|
364
|
+
// now wins. Only published once there is a position to resolve them against.
|
|
365
|
+
if (position.lineBearing != null) {
|
|
366
|
+
const effective = effectiveVmg(position.cog, position.sog, position.lineBearing,
|
|
367
|
+
position.toZoneVz, position.ocs, position.closestEnd);
|
|
368
|
+
for (const leg of ['toLine', 'alongLine']) {
|
|
369
|
+
const value = state.startLine ? Math.round(effective[leg] * 1000) / 1000 : null;
|
|
370
|
+
if (state.publishedEffectiveVmg[leg] !== value) {
|
|
371
|
+
state.publishedEffectiveVmg[leg] = value;
|
|
372
|
+
deltas.push({path: `navigation.racing.effectiveVmg.${leg}`, value});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
358
375
|
}
|
|
359
376
|
|
|
360
377
|
if (deltas.length > 0)
|
|
@@ -720,6 +737,14 @@ module.exports = (app) => {
|
|
|
720
737
|
if (!args)
|
|
721
738
|
return complete(callback, 400, 'Failed to set best VMG: no value');
|
|
722
739
|
|
|
740
|
+
if (args.command === 'clear') {
|
|
741
|
+
// Throw away everything collected, not just the manual adjustments, so
|
|
742
|
+
// the estimate starts again from what the boat does next.
|
|
743
|
+
resetVmgSamples();
|
|
744
|
+
publishBestVmg();
|
|
745
|
+
return complete(callback, 200, 'Cleared best VMG samples: OK');
|
|
746
|
+
}
|
|
747
|
+
|
|
723
748
|
if (args.command === 'reset') {
|
|
724
749
|
// No vmg name clears every override.
|
|
725
750
|
if (!clearBestVmgOverrides(args.vmg))
|
|
@@ -1036,12 +1061,11 @@ module.exports = (app) => {
|
|
|
1036
1061
|
angle = 180 - (bearingToEnd - startLine.bearing);
|
|
1037
1062
|
}
|
|
1038
1063
|
angle = ((angle + 180) % 360 + 360) % 360 - 180;
|
|
1039
|
-
const ocs = angle < 0;
|
|
1040
1064
|
|
|
1041
|
-
//
|
|
1042
|
-
// -
|
|
1043
|
-
// -
|
|
1044
|
-
// perpendicular distance
|
|
1065
|
+
// The start zone is a 45 degree wedge off each end of the line.
|
|
1066
|
+
// - Inside the zone, distanceToLine = perpendicular to the line (or extension).
|
|
1067
|
+
// - Outside it, distanceToLine = distance parallel to the line out to the
|
|
1068
|
+
// wedge, plus that perpendicular distance: an L whose corner sits on the 45.
|
|
1045
1069
|
// This is illustrated for the starboard closest end of the line below:
|
|
1046
1070
|
// \ /
|
|
1047
1071
|
// \ /
|
|
@@ -1053,24 +1077,10 @@ module.exports = (app) => {
|
|
|
1053
1077
|
// / \
|
|
1054
1078
|
//
|
|
1055
1079
|
// P=pin; B=boat; x=extension; b=perpToBoat; z=zoneEntry; V=vessel; N=north
|
|
1056
|
-
// bearing = NBP
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
// PBV = abs(angle)
|
|
1061
|
-
// VBx = 180 - PBV
|
|
1062
|
-
// Vx = toEnd * sin (VBx)
|
|
1063
|
-
// bBV = 90 - VBx
|
|
1064
|
-
// Vb = toEnd * sin (bBV)
|
|
1065
|
-
// zb = sqrt((Vx * Vx) / 2)
|
|
1066
|
-
// Vz = Vb - zb
|
|
1067
|
-
const anglePBV = Math.abs(angle);
|
|
1068
|
-
const inStartZone = anglePBV <= 135;
|
|
1069
|
-
const angleVBx = 180 - anglePBV;
|
|
1070
|
-
const perpToLineVx = toEnd * Math.sin(toRadians(angleVBx));
|
|
1071
|
-
let toZoneVz = inStartZone ? 0 : ( toEnd * Math.sin(toRadians(90 - angleVBx)) - Math.sqrt(perpToLineVx * perpToLineVx / 2.0));
|
|
1072
|
-
const toLine = Math.round( 10 * (toZoneVz + perpToLineVx)) / 10;
|
|
1073
|
-
const distanceToLine = ocs ? -toLine : toLine;
|
|
1080
|
+
// bearing = NBP, PBz == 135, bBz == zBx == 45, Bb == bz == Vx == perpendicular
|
|
1081
|
+
// distance to the line or extension, PBV = abs(angle), VBx = 180 - PBV.
|
|
1082
|
+
// The geometry itself lives in racer.js so it can be unit tested.
|
|
1083
|
+
const {ocs, perpToLineVx, toZoneVz, distanceToLine} = startZoneDistances(toEnd, angle);
|
|
1074
1084
|
app.debug('distanceToLine:' + distanceToLine);
|
|
1075
1085
|
state.distanceToLine = distanceToLine;
|
|
1076
1086
|
|
|
@@ -1081,14 +1091,18 @@ module.exports = (app) => {
|
|
|
1081
1091
|
collectVmgSamples(cog, sog, startLine.bearing, toZoneVz, perpToLineVx);
|
|
1082
1092
|
}
|
|
1083
1093
|
const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd, state.timeToStart ?? 0);
|
|
1084
|
-
|
|
1094
|
+
// Time to burn stands when OCS too: the time to line is then the time to get
|
|
1095
|
+
// back over the line from the course side, so the spare time before you have
|
|
1096
|
+
// to turn and do it is just as real - and goes negative when you have left it
|
|
1097
|
+
// too late, which is exactly when it is worth reading.
|
|
1098
|
+
const ttb = state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
|
|
1085
1099
|
|
|
1086
1100
|
sendDeltas([
|
|
1087
1101
|
{path: 'navigation.racing.distanceStartline', value: distanceToLine},
|
|
1088
1102
|
{path: "navigation.racing.timeToLine", value: ttl},
|
|
1089
1103
|
{path: "navigation.racing.timeToBurn", value: ttb}
|
|
1090
1104
|
]);
|
|
1091
|
-
publishBestVmg({ocs, toZoneVz, closestEnd});
|
|
1105
|
+
publishBestVmg({ocs, toZoneVz, closestEnd, cog, sog, lineBearing: startLine.bearing});
|
|
1092
1106
|
|
|
1093
1107
|
} else if (state.distanceToLine) {
|
|
1094
1108
|
state.distanceToLine = null;
|
|
@@ -1242,6 +1256,7 @@ module.exports = (app) => {
|
|
|
1242
1256
|
initRacer({
|
|
1243
1257
|
minSog: options.minSog ?? 1.0,
|
|
1244
1258
|
minVmg: options.minVmg ?? 0.01,
|
|
1259
|
+
minEffectiveVmg: (options.minEffectiveVmg ?? 0.5) * 0.514444,
|
|
1245
1260
|
maxDistance: options.maxDistance ?? 2000,
|
|
1246
1261
|
maxSamples: options.maxSamples ?? 600,
|
|
1247
1262
|
percentile: options.percentile ?? 0.9
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-racer",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
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": {
|
package/public/index.html
CHANGED
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
</head>
|
|
112
112
|
<body>
|
|
113
113
|
|
|
114
|
+
<script src="line-view.js"></script>
|
|
114
115
|
<script>
|
|
115
116
|
let socket;
|
|
116
117
|
let statusTimer;
|
|
@@ -133,24 +134,12 @@
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
const KNOTS_TO_MS = 0.514444;
|
|
136
|
-
const EARTH_RADIUS = 6371000;
|
|
137
137
|
const VMG_NAMES = ['toCourseSide', 'fromCourseSide', 'toPortEnd', 'toStbEnd'];
|
|
138
138
|
|
|
139
139
|
// What the two projections from the boat mean. Shown on hover rather than as a
|
|
140
140
|
// drawn legend, so the visualization stays clean.
|
|
141
|
-
const LEGEND_CURRENT = 'Current cog/sog to start';
|
|
142
|
-
const LEGEND_BEST = 'Best VMG to start';
|
|
143
|
-
const LEGEND_STUB = 'Current course over ground (no timer running)';
|
|
144
141
|
|
|
145
|
-
function formatKnots(metresPerSecond) {
|
|
146
|
-
return metresPerSecond == null ? '--' : `${(metresPerSecond / KNOTS_TO_MS).toFixed(1)}kn`;
|
|
147
|
-
}
|
|
148
142
|
|
|
149
|
-
function formatBearing(radians) {
|
|
150
|
-
if (radians == null) return '--';
|
|
151
|
-
const degrees = ((radians * 180 / Math.PI) % 360 + 360) % 360;
|
|
152
|
-
return `${degrees.toFixed(0).padStart(3, '0')}°T`;
|
|
153
|
-
}
|
|
154
143
|
|
|
155
144
|
// Everything the line visualization needs, filled in from the delta stream.
|
|
156
145
|
const nav = {
|
|
@@ -164,9 +153,12 @@
|
|
|
164
153
|
timerRunning: false,
|
|
165
154
|
timeToLine: null,
|
|
166
155
|
distanceToLine: null,
|
|
156
|
+
boatLength: null,
|
|
157
|
+
twd: null,
|
|
158
|
+
effVmgToLine: null,
|
|
159
|
+
effVmgAlongLine: null,
|
|
167
160
|
lineLength: null,
|
|
168
161
|
lineBearing: null,
|
|
169
|
-
bestApproach: null,
|
|
170
162
|
bestVmg: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null},
|
|
171
163
|
overrides: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null}
|
|
172
164
|
};
|
|
@@ -184,203 +176,22 @@
|
|
|
184
176
|
});
|
|
185
177
|
}
|
|
186
178
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
const lat0 = origin.latitude * Math.PI / 180;
|
|
191
|
-
return {
|
|
192
|
-
e: (point.longitude - origin.longitude) * Math.PI / 180 * Math.cos(lat0) * EARTH_RADIUS,
|
|
193
|
-
n: (point.latitude - origin.latitude) * Math.PI / 180 * EARTH_RADIUS
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Resolve the line into the coordinate system the drawing uses:
|
|
198
|
-
// a = distance along the line from the starboard (boat) end towards the port (pin) end
|
|
199
|
-
// c = distance across the line, positive on the pre-start side (negative is OCS)
|
|
200
|
-
function lineGeometry() {
|
|
201
|
-
if (!nav.port || !nav.stb) return null;
|
|
202
|
-
|
|
203
|
-
const v = offsetFrom(nav.stb, nav.port);
|
|
204
|
-
const length = Math.hypot(v.e, v.n);
|
|
205
|
-
if (!(length > 0)) return null;
|
|
206
|
-
|
|
207
|
-
// Unit vector along the line, starboard end -> port end.
|
|
208
|
-
const u = {e: v.e / length, n: v.n / length};
|
|
209
|
-
// The pre-start side lies 90 degrees anticlockwise of the line bearing.
|
|
210
|
-
const across = {e: -u.n, n: u.e};
|
|
211
|
-
const bearing = (Math.atan2(u.e, u.n) * 180 / Math.PI + 360) % 360;
|
|
212
|
-
|
|
213
|
-
let boat = null;
|
|
214
|
-
if (nav.position && typeof nav.position.latitude === 'number') {
|
|
215
|
-
const p = offsetFrom(nav.stb, nav.position);
|
|
216
|
-
boat = {
|
|
217
|
-
a: p.e * u.e + p.n * u.n,
|
|
218
|
-
c: p.e * across.e + p.n * across.n
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return {length, bearing, boat};
|
|
223
|
-
}
|
|
179
|
+
// The drawing itself lives in line-view.js, ported from the KIP widget so both
|
|
180
|
+
// show the same picture. It holds its own fitted view between renders.
|
|
181
|
+
let lineView = null;
|
|
224
182
|
|
|
225
183
|
function renderLine() {
|
|
226
184
|
const svg = document.getElementById('lineSvg');
|
|
227
185
|
const info = document.getElementById('lineInfo');
|
|
228
186
|
if (!svg) return;
|
|
187
|
+
if (!lineView) lineView = RacerLineView.create(svg);
|
|
188
|
+
lineView.render(nav);
|
|
229
189
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (info) info.textContent = '';
|
|
235
|
-
return;
|
|
190
|
+
if (info) {
|
|
191
|
+
const known = nav.port && nav.stb && nav.position
|
|
192
|
+
&& typeof nav.position.latitude === 'number';
|
|
193
|
+
info.textContent = known ? (nav.distanceToLine < 0 ? 'OCS' : 'behind line') : '';
|
|
236
194
|
}
|
|
237
|
-
|
|
238
|
-
const W = 400, H = 260, margin = 34;
|
|
239
|
-
const boat = geo.boat;
|
|
240
|
-
|
|
241
|
-
// Fit the line and the boat, keeping the across axis from collapsing when
|
|
242
|
-
// the boat is sitting on the line.
|
|
243
|
-
const minA = Math.min(0, boat ? boat.a : 0);
|
|
244
|
-
const maxA = Math.max(geo.length, boat ? boat.a : geo.length);
|
|
245
|
-
const minC = Math.min(0, boat ? boat.c : 0);
|
|
246
|
-
const maxC = Math.max(0, boat ? boat.c : 0);
|
|
247
|
-
|
|
248
|
-
const spanA = Math.max(maxA - minA, 1);
|
|
249
|
-
const spanC = Math.max(maxC - minC, spanA * 0.35);
|
|
250
|
-
const scale = Math.min((W - 2 * margin) / spanA, (H - 2 * margin) / spanC);
|
|
251
|
-
|
|
252
|
-
const midA = (minA + maxA) / 2;
|
|
253
|
-
const midC = (minC + maxC) / 2;
|
|
254
|
-
// The port (pin) end draws to the left, so the along axis runs against screen x.
|
|
255
|
-
const sx = (a) => W / 2 - (a - midA) * scale;
|
|
256
|
-
// The pre-start side draws below the line, so across runs with screen y.
|
|
257
|
-
const sy = (c) => H / 2 + (c - midC) * scale;
|
|
258
|
-
|
|
259
|
-
const portX = sx(geo.length), stbX = sx(0), lineY = sy(0);
|
|
260
|
-
const parts = [];
|
|
261
|
-
|
|
262
|
-
parts.push(`<line x1="${portX}" y1="${lineY}" x2="${stbX}" y2="${lineY}"` +
|
|
263
|
-
` stroke="#ddd" stroke-width="2" stroke-dasharray="6 5"/>`);
|
|
264
|
-
|
|
265
|
-
// Prefer the published length and bearing, falling back to the computed geometry.
|
|
266
|
-
const length = nav.lineLength != null ? nav.lineLength : geo.length;
|
|
267
|
-
const lineBearing = nav.lineBearing != null
|
|
268
|
-
? (nav.lineBearing * 180 / Math.PI + 360) % 360
|
|
269
|
-
: geo.bearing;
|
|
270
|
-
// The heading worth reporting is the one you sail to cross the line: perpendicular
|
|
271
|
-
// to it, towards the course side. That is 90 degrees clockwise of the line bearing
|
|
272
|
-
// (which runs starboard end -> port end), and in this line-up view it is always
|
|
273
|
-
// straight up the screen.
|
|
274
|
-
const startBearing = (lineBearing + 90) % 360;
|
|
275
|
-
|
|
276
|
-
// Length and start heading sit just on the OCS (course) side of the line, which
|
|
277
|
-
// is always the upper side here. Haloed so the dashes do not show through; the
|
|
278
|
-
// line can be shorter than the label, which is accepted.
|
|
279
|
-
const label = `${length.toFixed(0)}m · ${startBearing.toFixed(0).padStart(3, '0')}°T`;
|
|
280
|
-
const labelY = lineY - 8;
|
|
281
|
-
const labelX = (portX + stbX) / 2 - 14;
|
|
282
|
-
parts.push(`<text x="${labelX}" y="${labelY}" text-anchor="middle" fill="#ddd"` +
|
|
283
|
-
` font-size="22" stroke="#000" stroke-width="6" paint-order="stroke">${label}</text>`);
|
|
284
|
-
|
|
285
|
-
// Arrow beside the heading, pointing the way to sail to start.
|
|
286
|
-
const ax = labelX + (label.length * 11.2) / 2 + 14;
|
|
287
|
-
parts.push(`<polygon points="${ax},${labelY - 20} ${ax - 7},${labelY - 4} ${ax + 7},${labelY - 4}"` +
|
|
288
|
-
` fill="#ddd" stroke="#000" stroke-width="3" paint-order="stroke"/>`);
|
|
289
|
-
|
|
290
|
-
// The end labels go on the pre-start side, clear of the heading label above.
|
|
291
|
-
parts.push(`<circle cx="${portX}" cy="${lineY}" r="7" fill="#f88" stroke="#fff"/>`);
|
|
292
|
-
parts.push(`<text x="${portX}" y="${lineY + 22}" text-anchor="middle" fill="#f88"` +
|
|
293
|
-
` font-size="12" stroke="#000" stroke-width="3" paint-order="stroke">Port</text>`);
|
|
294
|
-
|
|
295
|
-
// A rough committee boat: hull with a raked bow, plus a cabin.
|
|
296
|
-
parts.push(`<polygon points="${stbX - 13},${lineY - 4} ${stbX + 11},${lineY - 4}` +
|
|
297
|
-
` ${stbX + 9},${lineY + 4} ${stbX - 7},${lineY + 4}"` +
|
|
298
|
-
` fill="#8f8" stroke="#fff" stroke-width="1"/>`);
|
|
299
|
-
parts.push(`<rect x="${stbX - 3}" y="${lineY - 10}" width="9" height="6"` +
|
|
300
|
-
` fill="#8f8" stroke="#fff" stroke-width="1"/>`);
|
|
301
|
-
parts.push(`<text x="${stbX}" y="${lineY + 22}" text-anchor="middle" fill="#8f8"` +
|
|
302
|
-
` font-size="12" stroke="#000" stroke-width="3" paint-order="stroke">Stb</text>`);
|
|
303
|
-
|
|
304
|
-
if (boat) {
|
|
305
|
-
const bx = sx(boat.a), by = sy(boat.c);
|
|
306
|
-
// A compass bearing b maps to the screen vector (-cos, -sin) of (b - lineBearing).
|
|
307
|
-
const screenVector = (bearing) => {
|
|
308
|
-
const t = bearing - geo.bearing * Math.PI / 180;
|
|
309
|
-
return {x: -Math.cos(t), y: -Math.sin(t)};
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
// Projections from the boat. While the timer counts down, the thick line runs
|
|
313
|
-
// along the current COG for the distance the boat will actually cover before
|
|
314
|
-
// the gun, so its tip shows where it gets to at zero: short of the line is
|
|
315
|
-
// late, beyond it is early. The thin faint line replays the course actually
|
|
316
|
-
// sailed behind the best VMG towards the line, on its own bearing, so the two
|
|
317
|
-
// tips compare where you are heading with where you could be heading.
|
|
318
|
-
// With no timer running only a course stub is drawn.
|
|
319
|
-
// What the two projections ended up being, so the boat's tooltip describes
|
|
320
|
-
// only the lines actually drawn.
|
|
321
|
-
let thickLabel = null;
|
|
322
|
-
let thinLabel = null;
|
|
323
|
-
if (nav.cog != null) {
|
|
324
|
-
const run = nav.timerRunning && nav.timeToStart > 0;
|
|
325
|
-
const v = screenVector(nav.cog);
|
|
326
|
-
// Clamped past the far corner of the viewBox: a long countdown projects
|
|
327
|
-
// well off the drawing, and it is clipped there anyway.
|
|
328
|
-
const project = (speed) => Math.min(speed * nav.timeToStart * scale, 600);
|
|
329
|
-
const cogLine = (length, width, opacity, label) =>
|
|
330
|
-
parts.push(`<line x1="${bx.toFixed(1)}" y1="${by.toFixed(1)}"` +
|
|
331
|
-
` x2="${(bx + v.x * length).toFixed(1)}" y2="${(by + v.y * length).toFixed(1)}"` +
|
|
332
|
-
` stroke="#6cf" stroke-width="${width}" stroke-opacity="${opacity}">` +
|
|
333
|
-
`<title>${label}</title></line>`);
|
|
334
|
-
|
|
335
|
-
if (run) {
|
|
336
|
-
// The thin line is a course the boat has genuinely sailed - the cog and
|
|
337
|
-
// sog recorded against the best VMG towards the line - so it runs on its
|
|
338
|
-
// own bearing, not the current one.
|
|
339
|
-
const approach = nav.bestApproach;
|
|
340
|
-
if (approach && approach.sog > 0) {
|
|
341
|
-
const av = screenVector(approach.cog);
|
|
342
|
-
const length = project(approach.sog);
|
|
343
|
-
parts.push(`<line x1="${bx.toFixed(1)}" y1="${by.toFixed(1)}"` +
|
|
344
|
-
` x2="${(bx + av.x * length).toFixed(1)}"` +
|
|
345
|
-
` y2="${(by + av.y * length).toFixed(1)}"` +
|
|
346
|
-
` stroke="#6cf" stroke-width="1" stroke-opacity="0.3">` +
|
|
347
|
-
`<title>${LEGEND_BEST}</title></line>`);
|
|
348
|
-
thinLabel = LEGEND_BEST;
|
|
349
|
-
}
|
|
350
|
-
if (nav.sog != null) {
|
|
351
|
-
cogLine(project(nav.sog), 3, 0.55, LEGEND_CURRENT);
|
|
352
|
-
thickLabel = LEGEND_CURRENT;
|
|
353
|
-
}
|
|
354
|
-
} else {
|
|
355
|
-
thickLabel = LEGEND_STUB;
|
|
356
|
-
cogLine(40, 3, 0.55, LEGEND_STUB);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const heading = nav.headingTrue != null ? nav.headingTrue : nav.cog;
|
|
361
|
-
const ok = heading != null;
|
|
362
|
-
const v = ok ? screenVector(heading) : {x: 0, y: -1};
|
|
363
|
-
const dx = v.x, dy = v.y;
|
|
364
|
-
const px = -dy, py = dx;
|
|
365
|
-
const pts = [
|
|
366
|
-
[bx + dx * 11, by + dy * 11],
|
|
367
|
-
[bx - dx * 7 + px * 6, by - dy * 7 + py * 6],
|
|
368
|
-
[bx - dx * 7 - px * 6, by - dy * 7 - py * 6]
|
|
369
|
-
].map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
|
370
|
-
// Hovering the boat reports what it is doing, and says what the two lines
|
|
371
|
-
// running from it mean - they are only described here, to keep the drawing
|
|
372
|
-
// itself uncluttered.
|
|
373
|
-
const tip = [`SOG ${formatKnots(nav.sog)} COG ${formatBearing(nav.cog)}`];
|
|
374
|
-
if (thickLabel) tip.push(`Thick line: ${thickLabel}`);
|
|
375
|
-
if (thinLabel) tip.push(`Thin line: ${thinLabel}`);
|
|
376
|
-
parts.push(`<polygon points="${pts}" fill="${boat.c < 0 ? '#fc0' : '#6cf'}"` +
|
|
377
|
-
` stroke="#fff" stroke-width="1"><title>${tip.join('\n')}</title></polygon>`);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
svg.innerHTML = parts.join('');
|
|
381
|
-
|
|
382
|
-
if (info)
|
|
383
|
-
info.textContent = boat ? (boat.c < 0 ? 'OCS' : 'behind line') : '';
|
|
384
195
|
}
|
|
385
196
|
|
|
386
197
|
// Time and distance to the line, flanking the top of the cross.
|
|
@@ -424,7 +235,10 @@
|
|
|
424
235
|
{path: "navigation.position", period: 1000},
|
|
425
236
|
{path: "navigation.courseOverGroundTrue", period: 1000},
|
|
426
237
|
{path: "navigation.headingTrue", period: 1000},
|
|
427
|
-
{path: "navigation.speedOverGround", period: 1000}
|
|
238
|
+
{path: "navigation.speedOverGround", period: 1000},
|
|
239
|
+
// The hull is drawn at true scale, and the wind against the line.
|
|
240
|
+
{path: "design.length", period: 10000},
|
|
241
|
+
{path: "environment.wind.directionTrue", period: 1000}
|
|
428
242
|
]
|
|
429
243
|
}));
|
|
430
244
|
};
|
|
@@ -474,8 +288,20 @@
|
|
|
474
288
|
nav.sog = value.value;
|
|
475
289
|
scheduleRender();
|
|
476
290
|
break;
|
|
477
|
-
case "navigation.racing.
|
|
478
|
-
nav.
|
|
291
|
+
case "navigation.racing.effectiveVmg.toLine":
|
|
292
|
+
nav.effVmgToLine = value.value;
|
|
293
|
+
scheduleRender();
|
|
294
|
+
break;
|
|
295
|
+
case "navigation.racing.effectiveVmg.alongLine":
|
|
296
|
+
nav.effVmgAlongLine = value.value;
|
|
297
|
+
scheduleRender();
|
|
298
|
+
break;
|
|
299
|
+
case "design.length.overall":
|
|
300
|
+
nav.boatLength = value.value;
|
|
301
|
+
scheduleRender();
|
|
302
|
+
break;
|
|
303
|
+
case "environment.wind.directionTrue":
|
|
304
|
+
nav.twd = value.value;
|
|
479
305
|
scheduleRender();
|
|
480
306
|
break;
|
|
481
307
|
case "navigation.racing.timeToLine":
|