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 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.bestApproach` | The course actually sailed that achieved the best VMG towards the line | `{rad,m/s}` | |
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
- Two projections run from the boat. Both start at the boat and are drawn to the same scale as the
149
- rest of the drawing, so their **tips can be read directly against the line**. Hovering the boat
150
- reports its SOG and COG and names whichever of the two lines are currently drawn.
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
- #### Thick line — "Current cog/sog to start"
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.2.0 Visualize line and best VMGs
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
- publishedBestApproach: 'null',
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.bestApproach",
164
+ "path": "navigation.racing.effectiveVmg.toLine",
159
165
  "value": {
160
- "type": "object",
161
- "description": "The course actually sailed that achieved the best VMG towards the line: {cog in rad, sog in m/s, direction}",
162
- "displayName": "Best approach course",
163
- "shortName": "Approach"
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 actual course behind the best VMG towards the line, rounded so a sample
348
- // change rather than float noise is what triggers a delta.
349
- const rounded = approach ? {
350
- cog: Math.round(approach.cog * 10000) / 10000,
351
- sog: Math.round(approach.sog * 1000) / 1000,
352
- direction: approach.direction
353
- } : null;
354
- const approachJson = JSON.stringify(rounded);
355
- if (state.publishedBestApproach !== approachJson) {
356
- state.publishedBestApproach = approachJson;
357
- deltas.push({path: 'navigation.racing.bestApproach', value: rounded});
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
- // We define a start zone which includes a 45° wedge off each end of the line.
1042
- // - If the boat is inside the start zone, distanceToLine = perpendicular to line (or extension).
1043
- // - If the boat is outside the start zone, distanceToLine = distance parallel to the line to enter the zone +
1044
- // perpendicular distance to the line (or extension).
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
- // PBz == 135
1058
- // bBz == zBx == 45
1059
- // Bb == bz == Vx == perpendicular distance to the line or extension.
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
- const ttb = !ocs && state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
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.0",
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
- // Offset of a point from the starboard end, in metres east/north.
188
- // Equirectangular is exact enough here: a start line is a few hundred metres.
189
- function offsetFrom(origin, point) {
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
- const geo = lineGeometry();
231
- if (!geo) {
232
- svg.innerHTML = '<text x="200" y="130" text-anchor="middle" fill="#888"' +
233
- ' font-size="14">No start line set</text>';
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.bestApproach":
478
- nav.bestApproach = value.value;
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":