signalk-racer 1.3.2 → 1.3.4

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
@@ -36,14 +36,23 @@ This plugin calculates and publishes the following Signal K paths:
36
36
  | `navigation.racing.timeToBurn` | Period of time delay before sailing to the at best VMG | `s` | |
37
37
  | `navigation.racing.startTime` | The start time as an ISO timestamp | `rfc3339` | |
38
38
 
39
- These values can be displayed in KIP widgets, Freeboard-SK, or other Signal K clients.
39
+ These values can be displayed in Skip/KIP widgets, Freeboard-SK, or other Signal K clients.
40
40
  There are dedicated widgets for the start timer and line adjustment since 3.5.0 of KIP
41
41
 
42
- ![KIP and Freeboard Screenshot](racer-kip.png)
42
+ ![Skip and Freeboard Screenshot](images/racer-skip.png)
43
43
 
44
- There is also a webapp to: adjust the line, visualize the approach to the line, adjust VMGs and configure the timer.
44
+ *Skip start line view and timer widgets besides Freeboard-SK*
45
+
46
+ ![KIP and Freeboard Screenshot](images/racer-kip.png)
47
+
48
+ *KIP racer timer and line widgets beside Freeboard-SK showing the next leg*
49
+
50
+ There is also a webapp to: adjust the line, visualise the approach to the line, adjust VMGs, and configure the timer. See the Skip widgets below for a full description of the line visualisation.
51
+
52
+ ![Signalk Racer](images/racer-webapp.png)
53
+
54
+ *The racer webapp: line adjustment, the approach to the line, best VMGs and the start timer*
45
55
 
46
- ![Signalk Racer Webapp](racer-webapp.png)
47
56
 
48
57
  ---
49
58
 
@@ -70,15 +79,17 @@ You can configure the plugin via the Signal K web interface or by editing `setti
70
79
 
71
80
  ### Waypoint selection (`navigation.racing.startLinePort` and `navigation.racing.startLineStb`)
72
81
 
73
- The `startLineStb` and `startLinePort` configurations are used to search resources for matching waypoints. These waypoints can be modified in real time (e.g. moving or setting in Freeboard SK) and the algorithm with update to the new line.
82
+ The `startLineStb` and `startLinePort` configurations are used to search resources for matching waypoints. These waypoints can be modified in real time (e.g. moving or setting in Freeboard SK) and the algorithm with update to the new line. Named lines can also be used to substitute different pairs of named waypoints to use as a line.
74
83
 
75
84
  ### Bow position
76
85
 
77
- If `navigation.headingTrue` is not available, then `navigation.courseOverGroundTrue` is used when determining the position of the bow relative to the GPS position.
86
+ The GPS position provided is assumed to be the **bow** of the boat. The offsets should be set in the signalk-server configuration to make this so. If the offsets are not set correctly, then the consistency in angle when pinging the line ends and approaching to start will compensate.
78
87
 
79
88
  ### Distance to Start Line (`navigation.racing.distanceStartline`)
80
89
 
81
- ![Distance to Start Line](racer-line.png)
90
+ ![Distance to Start Line](images/racer-line.png)
91
+
92
+ *The start zone: inside it the distance is straight across to the line, outside it the boat must first reach the zone*
82
93
 
83
94
  - Computes the distance from the **bow** of the boat to the configured start line.
84
95
  - 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.
@@ -152,13 +163,22 @@ Runs along the boat's present `navigation.courseOverGroundTrue`. While the start
152
163
  down, its length is `SOG × timeToStart`: the distance the boat will cover before the gun if nothing
153
164
  changes. Its tip is therefore **where the boat will be when the start fires**:
154
165
 
155
- - tip short of the line — the boat is late and will cross after the gun
166
+ - tip short of the line — the boat will be late at current speed and heading.
156
167
  - tip on the line — a perfect start
157
- - tip beyond the line — the boat is early and will be OCS
168
+ - tip beyond the line — the boat is early and will be OCS unless speed reduces or heading changes.
158
169
 
159
170
  With no timer running there is nothing to project against, so it degrades to a short fixed-length
160
171
  stub showing course only.
161
172
 
173
+ #### VMG projection — "Best VMG to start"
174
+
175
+ A round hollow dot is displayed along the distance to line vectors that shows where the boat could be if it sails its best VMG into the zone and then its best VMG to the line. If the dot is not displayed, then either the timer has not started or the best VMG takes the boat beyond the current view.
176
+
177
+ Ideally, when approaching the line, the VMG projection should be just over the line and the Course projection just behind the line. This indicates that the boat is sailing near to the line at the start and can speed up to be exactly on time.
178
+
179
+ See the Skip widgets below for a full description of the line visualisation.
180
+
181
+
162
182
  ---
163
183
 
164
184
  ## 🌐 API Access
@@ -233,16 +253,86 @@ Used to **start**, **sync**, **reset**, or **set a fixed start time**.
233
253
  ```
234
254
  ---
235
255
 
236
- ## 🧩 KIP Widgets
237
- KIP components for `racer-timer` and `racer-line` have been developed and are available from release 3.5.0 of KIP.
256
+ ## 🧩 Skip Widgets
257
+ The Skip instrument plugin includes widgets for `racer-timer`, `racer-line` and `race-line-view`. The KIP plugin also contains older `racer-timer` and `racer-line` widgets.
258
+
259
+ ![Skip Line View: Approaching the start zone](images/racer-skip-approach.png)
260
+
261
+ *Approaching the start zone*
262
+
263
+ The `skip-line-view` widget is a graphical alternative to the `racer-line` widget, both have similar features.
264
+ The screenshot above shows the Skip `racer-line-view` widget with:
265
+ + Line always displayed horizontally, with extension lines and 45-degree approaches
266
+ + The current True Wind Direction (TWD) is shown in the top right
267
+ + The distance the boat needs to cover to both reach the start zone (72m) and to reach the line (21m) is shown.
268
+ + The blue projection line showing where the boat will be at the start time on the current course and speed (COG/SOG)
269
+ + The best VMG dot showing where the boat could be if it sails to the zone and then the line at its best VMG.
270
+ + The time to line (0:43) is shown in seconds for the boat's best VMG.
271
+ + The time to burn (0:12) is shown in seconds for the time the boat must waste to arrive on the line on time.
272
+ + The '...' mode selector to access other views/configurations.
273
+
274
+
275
+ ![Skip Line View: Final Approach](images/racer-skip-final.png)
276
+
277
+ *Final Approach to the start line*
278
+
279
+ The screenshot above shows the Skip `racer-line-view` widget with the boat in the final approach to the line: 4s/11m from the line with 2s to burn. The current course projection has not crossed the line, but the best VMG dot is just over the line. This view indicates that boat should accelerate to full speed in 2 seconds for a perfect start.
280
+
281
+ ![Skip Line View: Good Start](images/racer-skip-started.png)
282
+
283
+ *Good start line goes Green*
284
+
285
+ On a good start, the line goes green and stays green until the timer is reset.
286
+
287
+ ![Skip Line View: OCS Start](images/racer-skip-ocs.png)
288
+
289
+ *OCS start line goes Red*
238
290
 
239
- ## 🔄 Dependencies
291
+ If the boat is on the course side (OCS) when the tim*Set start line*counts to zero, then the line goes red and stays green until the timer is reset.
292
+
293
+ ![Skip Line View: Set Line](images/racer-skip-set-line.png)
294
+
295
+ *Set the line*
296
+
297
+ The first mode screen selected from the `racer-line-view` widget displays the current line length and perpendicular header. The ends of the lines may be "pinged" by press the button at either either end. Alternately, a known line can be configured in the plugin configuration and selected by name on this screen.
298
+
299
+
300
+ ![Skip Line View: Adjust the Line](images/racer-skip-adjust-line.png)
301
+
302
+ *Adjust the line*
303
+
304
+ The second mode screen selected from the `racer-line-view` widget allows each ends of the line to be adjusted manually if there is no time to "ping" an end after it has been moved or incorrectly set.
305
+
306
+ ![Skip Line View: Best VMGs](images/racer-skip-vmgs.png)
307
+
308
+ *Best VMGs*
309
+
310
+ The third mode screen selected from the `racer-line-view` widget displays the effective best VMGs relative to the line that have been calculated by the plugin. These may be selected and manually adjusted with the "VMG" button; "Reset" to remove all manual adjustments; or "Clear" to discard all collected VMG samples.
311
+
312
+ ![Skip Timer: Start Timer](images/racer-timer-start.png)
313
+
314
+ *Start Timer*
315
+
316
+ The screenshot above shows the Skip `racer-timer` widget with the start button available to start the timer and the +/- minute adjustment buttons. Other views are available via the "..." mode button.
317
+
318
+ ![Skip Timer: Adjust Timer](images/racer-timer-start.png)
319
+
320
+ *Adjust Timer*
321
+
322
+ The adjust mode screen of the `racer-timer` widget allows the start time to be adjusted by +/- minutes or +/- seconds, even when the timer is running.
323
+
324
+ ![Skip Timer: Set Timer](images/racer-timer-set.png)
325
+
326
+ *Set Timer*
327
+
328
+ The set mode screen of the `racer-timer` widget allows the start time to be set to a specific absolute time. Perfect for pursuit style races.
329
+
330
+ ![Skip Timer: Sync Timer](images/racer-timer-sync.png)
331
+
332
+ *Sync Timer*
333
+
334
+ The sync mode screen of the `racer-timer` widget allows the start time to be synced to the nearest whole minute, or reset to the initial value.
240
335
 
241
- ### Plugins
242
- - [KIP >= 3.5.0](https://github.com/mxtommy/Kip) for graphic widget support
243
- - [FreeboardSK](https://github.com/SignalK/freeboard-sk#readme) for graphic display of the start line
244
- - [resources-provider](https://www.npmjs.com/package/@signalk/resources-provider) plugin with waypoints enabled
245
- - [course-provider](https://www.npmjs.com/package/@signalk/course-provider) plugin to enable the next leg calculations
246
336
 
247
337
  ### Libraries
248
338
  - `geolib`: for distance and bearing calculations.
@@ -251,8 +341,7 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
251
341
 
252
342
  ## 🧪 Future Plans
253
343
 
254
- - Add calculations for:
255
- - **Time to burn**
344
+ - **Polars** - include polars in the effective VMG calculations
256
345
  - **Laylines** and distance/time to them
257
346
 
258
347
  ---
@@ -261,7 +350,6 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
261
350
  Bug reports and suggestions are welcome at
262
351
  [https://github.com/gregw/signalk-racer](https://github.com/gregw/signalk-racer)
263
352
 
264
-
265
353
  ---
266
354
  ## Development
267
355
 
package/ReleaseNotes.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 1.3.4 Updated presentation
2
+ + New logo
3
+ + Named just "Signalk Racer"
4
+
5
+ ## 1.3.3 Updated for Skip widgets
6
+ + Updated the line visualisation to match the latest skip widget.
7
+ + Update the documentation to reflect the latest skip widget.
8
+
1
9
  ## 1.3.2 Distance to length
2
10
  + Set the metadata for distance to line as a length rather than as a distance.
3
11
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -1284,6 +1284,7 @@ module.exports = (app) => {
1284
1284
  app.setPluginStatus?.(`signalk-racer ${pluginVersion} started`);
1285
1285
  app.debug(`signalk-racer ${pluginVersion} loaded from ${__dirname}`);
1286
1286
 
1287
+ // These offsets are no longer sent, with the position itself being corrected.
1287
1288
  let fromBow = app.getSelfPath('sensors.gps.fromBow');
1288
1289
  let fromCenter = app.getSelfPath('sensors.gps.fromCenter');
1289
1290
  state.options = options;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-racer",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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": {
@@ -15,7 +15,7 @@
15
15
  "signalk-plugin-enabled-by-default": false,
16
16
  "signalk": {
17
17
  "appIcon": "assets/signalk-racer-72x72.png",
18
- "displayName": "Signalk Racer WebApp"
18
+ "displayName": "Signalk Racer"
19
19
  },
20
20
  "author": "gregw@mortbay.com",
21
21
  "license": "Apache-2.0",
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
2
+ <rect width="72" height="72" rx="12" fill="#14202e"/>
3
+ <!-- the line extended, and the start zone wedge on the pre-start side -->
4
+ <g stroke="#6b7884" stroke-width="1.6" stroke-dasharray="3 3" fill="none">
5
+ <path d="M0 27.5 H72"/>
6
+ <path d="M12 27.5 L-12 51.5"/>
7
+ <path d="M44 27.5 L80.5 64"/>
8
+ </g>
9
+ <!-- the start line -->
10
+ <path d="M12 27.5 H44" stroke="#e4e8ec" stroke-width="3.5" stroke-dasharray="5.5 3"/>
11
+ <!-- wind -->
12
+ <path d="M30.5 3 V11 H26 L33 18.5 L40 11 H35.5 V3 Z" fill="#c8cdd2"/>
13
+ <!-- time to burn, 0:06, on a seven segment mast display -->
14
+ <g transform="skewX(-8) translate(7 0)">
15
+ <g fill="#ffb000" fill-opacity="0.06"><polygon points="4.00,57.00 5.20,55.80 10.80,55.80 12.00,57.00 10.80,58.20 5.20,58.20"/><polygon points="21.50,57.00 22.70,55.80 28.30,55.80 29.50,57.00 28.30,58.20 22.70,58.20"/><polygon points="42.50,49.50 43.70,50.70 43.70,55.30 42.50,56.50 41.30,55.30 41.30,50.70"/></g>
16
+ <g fill="#ffb000"><polygon points="4.00,49.00 5.20,47.80 10.80,47.80 12.00,49.00 10.80,50.20 5.20,50.20"/><polygon points="12.50,49.50 13.70,50.70 13.70,55.30 12.50,56.50 11.30,55.30 11.30,50.70"/><polygon points="12.50,57.50 13.70,58.70 13.70,63.30 12.50,64.50 11.30,63.30 11.30,58.70"/><polygon points="4.00,65.00 5.20,63.80 10.80,63.80 12.00,65.00 10.80,66.20 5.20,66.20"/><polygon points="3.50,57.50 4.70,58.70 4.70,63.30 3.50,64.50 2.30,63.30 2.30,58.70"/><polygon points="3.50,49.50 4.70,50.70 4.70,55.30 3.50,56.50 2.30,55.30 2.30,50.70"/><polygon points="21.50,49.00 22.70,47.80 28.30,47.80 29.50,49.00 28.30,50.20 22.70,50.20"/><polygon points="30.00,49.50 31.20,50.70 31.20,55.30 30.00,56.50 28.80,55.30 28.80,50.70"/><polygon points="30.00,57.50 31.20,58.70 31.20,63.30 30.00,64.50 28.80,63.30 28.80,58.70"/><polygon points="21.50,65.00 22.70,63.80 28.30,63.80 29.50,65.00 28.30,66.20 22.70,66.20"/><polygon points="21.00,57.50 22.20,58.70 22.20,63.30 21.00,64.50 19.80,63.30 19.80,58.70"/><polygon points="21.00,49.50 22.20,50.70 22.20,55.30 21.00,56.50 19.80,55.30 19.80,50.70"/><polygon points="34.00,49.00 35.20,47.80 40.80,47.80 42.00,49.00 40.80,50.20 35.20,50.20"/><polygon points="42.50,57.50 43.70,58.70 43.70,63.30 42.50,64.50 41.30,63.30 41.30,58.70"/><polygon points="34.00,65.00 35.20,63.80 40.80,63.80 42.00,65.00 40.80,66.20 35.20,66.20"/><polygon points="33.50,57.50 34.70,58.70 34.70,63.30 33.50,64.50 32.30,63.30 32.30,58.70"/><polygon points="33.50,49.50 34.70,50.70 34.70,55.30 33.50,56.50 32.30,55.30 32.30,50.70"/><polygon points="34.00,57.00 35.20,55.80 40.80,55.80 42.00,57.00 40.80,58.20 35.20,58.20"/><rect x="15.70" y="52.70" width="2.2" height="2.2"/><rect x="15.70" y="59.10" width="2.2" height="2.2"/></g>
17
+ </g>
18
+ <!-- boat on the starboard lay line, closing on the committee boat -->
19
+ <path d="M10 0 C6 2.6 -1 4.4 -10 3 L-10 -3 C-1 -4.4 6 -2.6 10 0 Z" fill="#6cf" stroke="#14202e" stroke-width="1" transform="translate(55 47) rotate(225)"/>
20
+ <!-- pin end and committee boat -->
21
+ <circle cx="12" cy="27.5" r="5" fill="#f55" stroke="#14202e" stroke-width="1.4"/>
22
+ <path d="M36.5 24.5 H52 L49 30.5 H39.5 Z" fill="#5d5" stroke="#14202e" stroke-width="1.2"/>
23
+ <rect x="41.5" y="19.5" width="6" height="5" fill="#5d5" stroke="#14202e" stroke-width="1.2"/>
24
+ </svg>
package/public/index.html CHANGED
@@ -71,7 +71,11 @@
71
71
  gap: 0 0.5em;
72
72
  }
73
73
 
74
- .viz { width: 100%; max-width: 400px; }
74
+ /* The drawing takes the full width of the window, at up to twice the widget's
75
+ original 260px height. Narrow windows get a shorter drawing so it keeps a
76
+ sensible aspect; the drawing widens its own viewBox to fill whatever it gets. */
77
+ .viz { width: 100%; }
78
+ #lineSvg { display: block; width: 100%; height: min(520px, max(260px, 65vw)); }
75
79
 
76
80
  .vmg-row { display: flex; align-items: center; justify-content: center; gap: 0.15em; }
77
81
 
@@ -151,6 +155,7 @@
151
155
  sog: null,
152
156
  timeToStart: null,
153
157
  timerRunning: false,
158
+ startTime: null,
154
159
  timeToLine: null,
155
160
  distanceToLine: null,
156
161
  boatLength: null,
@@ -325,6 +330,7 @@
325
330
  // The plugin clears startTime whenever the timer is not
326
331
  // counting down, so it doubles as the running flag.
327
332
  nav.timerRunning = !!value.value;
333
+ nav.startTime = value.value || null;
328
334
  scheduleRender();
329
335
  if (!value.value) {
330
336
  document.getElementById('startTime').value = "HH:MM:SS";
@@ -425,6 +431,7 @@
425
431
  nav.sog = leaf(data.speedOverGround);
426
432
  nav.timeToStart = leaf(racing.timeToStart);
427
433
  nav.timerRunning = !!leaf(racing.startTime);
434
+ nav.startTime = leaf(racing.startTime);
428
435
  nav.timeToLine = leaf(racing.timeToLine);
429
436
  nav.distanceToLine = leaf(racing.distanceStartline);
430
437
 
@@ -602,6 +609,9 @@
602
609
 
603
610
  // connect WS and load lines once the page is running
604
611
  connectWebSocket();
612
+ // The drawing sizes its viewBox to the box it is given, so redraw when that changes.
613
+ document.addEventListener('DOMContentLoaded', () =>
614
+ new ResizeObserver(() => scheduleRender()).observe(document.getElementById('lineSvg')));
605
615
  loadStartLines();
606
616
  loadInitialState();
607
617
 
@@ -725,7 +735,7 @@
725
735
  The names are on the title attributes for hover. -->
726
736
  <div class="viz-row">
727
737
  <div class="viz">
728
- <svg id="lineSvg" viewBox="0 0 400 260" width="100%" style="max-width: 400px;"></svg>
738
+ <svg id="lineSvg" viewBox="0 0 400 260" preserveAspectRatio="xMidYMid meet"></svg>
729
739
  <div id="lineInfo" style="font-size: 0.9em; color: #aaa;"></div>
730
740
  </div>
731
741
 
@@ -1,9 +1,11 @@
1
1
  /*
2
- * The start line drawing, shared in substance with the KIP racer widgets.
2
+ * The start line drawing, shared in substance with the Skip racer-line-view widget.
3
3
  *
4
- * Ported by hand from the Angular component in KIP (racer-line-view), so the webapp and
5
- * the widgets show the same picture of the same numbers. Kept as plain browser JS with
6
- * no build step, matching the rest of this webapp.
4
+ * Ported by hand from the watching screen of the Angular component in Skip
5
+ * (widget-racer-line-view/racer-line-view), so the webapp and the widget show the same
6
+ * picture of the same numbers. The widget's editing screens and in-drawing controls are
7
+ * not ported: the webapp has its own buttons for those. Kept as plain browser JS with no
8
+ * build step, matching the rest of this webapp.
7
9
  *
8
10
  * The line is drawn "line up": always horizontal, port (pin) end to the left and
9
11
  * starboard (committee boat) end to the right, so the course side is always the upper
@@ -16,21 +18,33 @@
16
18
  // Drawing coordinate system. The height is fixed so font sizes scale with the box;
17
19
  // the width tracks its aspect so the drawing fills it undistorted.
18
20
  const VB_HEIGHT = 260;
19
- const TOP_BAND = 26; // kept clear for the title
21
+ const TOP_BAND = 26; // kept clear at the top, as the widget keeps it for its title
20
22
  const BOTTOM_MARGIN = 26; // the line never enters the bottom 10%
21
23
  const MARGIN = 28;
22
- const LABEL_FONT = 36;
24
+ const LABEL_FONT = 28.8;
23
25
  const LABEL_HEADROOM = 56; // room above the line for its label
26
+ const LEG_FONT = 22.5; // the approach legs' dimension labels
24
27
  const END_METRES = 10; // both ends are taken to be 10m objects...
25
28
  const END_EXAGGERATION = 2; // ...drawn at this multiple so they stay legible
29
+ const END_SIZE_MAX = 40;
26
30
  const DEFAULT_BOAT_METRES = 10;
27
31
  const HULL_BEAM_RATIO = 0.36;
32
+ const MIN_HULL_DISPLAY_UNITS = 20;
33
+ // Boat lengths of line the fit keeps in frame before it will zoom in any further.
34
+ const LINE_IN_FRAME_LENGTHS = 10;
28
35
  const MIN_EFFECTIVE_VMG = 0.514444; // one knot, as the plugin floors it
29
- const VIEW_SMOOTHING = 10; // percent of the height the view may drift before re-fitting
36
+ const VIEW_SMOOTHING = 25; // percent of the height the view may drift before re-fitting
37
+ // How old the last fix may be and still judge the gun, when the position is lost by then.
38
+ const LAST_FIX_AT_GUN_MS = 15000;
39
+
40
+ const LEGEND_CURRENT = 'Current cog/sog to start';
41
+ const LEGEND_STUB = 'Current course over ground (no timer running)';
30
42
 
31
43
  const COLOUR = {
32
44
  line: '#999', port: '#f88', stb: '#8f8', boat: '#6cf', ocs: '#fc0',
33
- dimension: '#FC0FC0', guide: '#888', label: '#ddd', halo: '#111'
45
+ lineOcs: '#f33', lineStarted: '#3c3',
46
+ dimension: '#FC0FC0', zoneLeg: '#b070ff', guide: '#999', label: '#999',
47
+ wind: '#ddd', halo: '#000'
34
48
  };
35
49
 
36
50
  /** Offset of a point from an origin, in metres east/north. */
@@ -94,33 +108,77 @@
94
108
  function create(svg) {
95
109
  let frame = null;
96
110
  let frameKey = null;
97
-
98
- /** Half the boat's drawn length in metres - a model quantity, so scale-free. */
99
- function boatHalfExtent(nav, c) {
100
- const metres = nav.boatLength || DEFAULT_BOAT_METRES;
101
- return (metres + Math.abs(c) / 5) / 2;
111
+ // The exact fit the held frame was made from. Drift is measured against this,
112
+ // since a frame that had to grow is held deliberately larger than its own fit.
113
+ let frameIdeal = null;
114
+
115
+ // Whether the countdown reached zero with the boat behind the line. Latched,
116
+ // because by the time it is worth showing nothing in the live data says so any
117
+ // more; only the next countdown ends it. See the Skip widget for the full story.
118
+ let startedClean = false;
119
+ let lastStartTime = null;
120
+ let countdownSeen = false;
121
+ let lastBoatSide = null;
122
+
123
+ /** How far the drawn boat reaches from its fix, in metres: the fix is the bow. */
124
+ function boatReach(nav) {
125
+ return nav.boatLength || DEFAULT_BOAT_METRES;
102
126
  }
103
127
 
104
128
  function bandTop() { return TOP_BAND + LABEL_HEADROOM; }
105
129
  function bandBottom() { return VB_HEIGHT - BOTTOM_MARGIN; }
106
130
 
107
- /** Fit the line and the boat, with the boat's drawn size allowed for. */
131
+ /** The end of the line the boat is nearer to, as a distance along the line. */
132
+ function nearestEnd(geo, a) {
133
+ return Math.abs(a) <= Math.abs(a - geo.length) ? 0 : geo.length;
134
+ }
135
+
136
+ /**
137
+ * Fit the boat and the nearer end of the line, with the boat's drawn size allowed
138
+ * for. The far end is let go when holding it would cost scale, but a run of at
139
+ * least LINE_IN_FRAME_LENGTHS boat lengths of line is always kept, and any width
140
+ * the scale leaves spare is spent reaching towards the far end.
141
+ */
108
142
  function fitFrame(nav, geo, W) {
109
143
  const yMin = bandTop(), yMax = bandBottom();
110
144
  const boat = geo.boat;
111
- const pad = boat ? boatHalfExtent(nav, boat.c) : 0;
112
- const minA = Math.min(0, boat ? boat.a - pad : 0);
113
- const maxA = Math.max(geo.length, boat ? boat.a + pad : geo.length);
145
+ const pad = boat ? boatReach(nav) : 0;
146
+
147
+ const ends = boat ? [nearestEnd(geo, boat.a)] : [0, geo.length];
148
+ let minA = Math.min.apply(null, ends.concat([boat ? boat.a - pad : 0]));
149
+ let maxA = Math.max.apply(null, ends.concat([boat ? boat.a + pad : geo.length]));
114
150
  const minC = Math.min(0, boat ? boat.c - pad : 0);
115
151
  const maxC = Math.max(0, boat ? boat.c + pad : 0);
116
- const spanA = Math.max(maxA - minA, 1);
152
+
153
+ // Keep a usable run of the line itself in frame, measured on the line.
154
+ const wanted = Math.min(LINE_IN_FRAME_LENGTHS * boatReach(nav), geo.length);
155
+ const visibleLo = Math.max(minA, 0), visibleHi = Math.min(maxA, geo.length);
156
+ if (visibleHi - visibleLo < wanted) {
157
+ const middle = (visibleLo + visibleHi) / 2;
158
+ let lo = middle - wanted / 2, hi = middle + wanted / 2;
159
+ if (lo < 0) { hi -= lo; lo = 0; }
160
+ if (hi > geo.length) { lo = Math.max(0, lo - (hi - geo.length)); hi = geo.length; }
161
+ minA = Math.min(minA, lo);
162
+ maxA = Math.max(maxA, hi);
163
+ }
164
+
117
165
  // Keeps the across axis from collapsing when the boat sits on the line.
166
+ const spanA = Math.max(maxA - minA, 1);
118
167
  const spanC = Math.max(maxC - minC, spanA * 0.35);
119
- return {
120
- scale: Math.min((W - 2 * MARGIN) / spanA, (yMax - yMin) / spanC),
121
- midA: (minA + maxA) / 2,
122
- midC: (minC + maxC) / 2
123
- };
168
+ const scale = Math.min((W - 2 * MARGIN) / spanA, (yMax - yMin) / spanC);
169
+
170
+ // Spend the width the chosen scale left over on the line, towards the far end.
171
+ // The scale is not recomputed: this is room already bought and standing empty.
172
+ if (boat) {
173
+ const spare = (W - 2 * MARGIN) / scale - (maxA - minA);
174
+ if (spare > 0) {
175
+ const far = nearestEnd(geo, boat.a) === 0 ? geo.length : 0;
176
+ if (far > maxA) maxA = Math.min(far, maxA + spare);
177
+ else if (far < minA) minA = Math.max(far, minA - spare);
178
+ }
179
+ }
180
+
181
+ return {scale: scale, midA: (minA + maxA) / 2, midC: (minC + maxC) / 2};
124
182
  }
125
183
 
126
184
  function place(f, W, a, c) {
@@ -130,10 +188,14 @@
130
188
  };
131
189
  }
132
190
 
133
- /** How far re-fitting would shift the drawing, as a fraction of its height. */
191
+ /**
192
+ * How far re-fitting would shift the drawing, as a fraction of its height,
193
+ * measured at the end being kept in frame and at the boat.
194
+ */
134
195
  function frameDrift(held, ideal, geo, W) {
196
+ const marks = geo.boat ? [nearestEnd(geo, geo.boat.a), geo.boat.a] : [0, geo.length];
135
197
  let worst = 0;
136
- [0, geo.length].forEach(function (a) {
198
+ marks.forEach(function (a) {
137
199
  const from = place(held, W, a, 0), to = place(ideal, W, a, 0);
138
200
  worst = Math.max(worst, Math.hypot(from.x - to.x, from.y - to.y));
139
201
  });
@@ -144,9 +206,9 @@
144
206
  const boat = geo.boat;
145
207
  if (!boat) return true;
146
208
  const p = place(f, W, boat.a, boat.c);
147
- const r = boatHalfExtent(nav, boat.c) * f.scale;
209
+ const r = boatReach(nav) * f.scale;
148
210
  return p.x - r >= 2 && p.x + r <= W - 2
149
- && p.y - r >= TOP_BAND && p.y + r <= VB_HEIGHT - 2;
211
+ && p.y - r >= TOP_BAND && p.y + r <= bandBottom();
150
212
  }
151
213
 
152
214
  /** The line must stay clear of the bottom, however long the frame is held. */
@@ -157,17 +219,57 @@
157
219
 
158
220
  function updateFrame(nav, geo, W) {
159
221
  const key = W + '|' + geo.length.toFixed(1);
222
+ const tolerance = VIEW_SMOOTHING / 100;
223
+ // A lost fix takes the boat away, not the line: hold the frame it was last
224
+ // seen in rather than zooming out to the line and straight back in.
225
+ if (!geo.boat && frame && key === frameKey) return frame;
226
+
160
227
  const ideal = fitFrame(nav, geo, W);
161
228
  if (!frame || key !== frameKey
162
- || frameDrift(frame, ideal, geo, W) > VIEW_SMOOTHING / 100
229
+ || frameDrift(frameIdeal || frame, ideal, geo, W) > tolerance
163
230
  || !lineFitsIn(frame)
164
231
  || !boatFitsIn(nav, frame, geo, W)) {
165
- frame = ideal;
232
+ // Overshoot a growth by the tolerance, so an approach does not stutter
233
+ // through a re-fit on every other update. Shrinking is not overshot.
234
+ const grow = frame && ideal.scale < frame.scale;
235
+ frame = grow
236
+ ? {scale: ideal.scale / (1 + tolerance), midA: ideal.midA, midC: ideal.midC}
237
+ : ideal;
238
+ frameIdeal = ideal;
166
239
  frameKey = key;
167
240
  }
168
241
  return frame;
169
242
  }
170
243
 
244
+ /**
245
+ * Watch the countdown through zero, latching a clean start when the gun finds the
246
+ * boat behind the line. Any new start time, or time to start with time still to
247
+ * run, ends the latch.
248
+ */
249
+ function watchGun(nav, geo) {
250
+ const startTime = nav.startTime || null;
251
+ const tts = nav.timeToStart;
252
+ const running = startTime != null;
253
+ const armed = running && startTime !== lastStartTime;
254
+ lastStartTime = startTime;
255
+ if (armed) countdownSeen = false;
256
+ if (tts != null && tts > 0) countdownSeen = true;
257
+ if (!geo) lastBoatSide = null;
258
+ else if (geo.boat) lastBoatSide = {c: geo.boat.c, at: Date.now()};
259
+
260
+ if (startedClean) {
261
+ if (armed || (tts != null && tts > 0)) startedClean = false;
262
+ return;
263
+ }
264
+ if (!running || !countdownSeen) return;
265
+ if (tts != null && tts <= 0) {
266
+ let side = geo && geo.boat ? geo.boat.c : null;
267
+ if (side == null && lastBoatSide && Date.now() - lastBoatSide.at <= LAST_FIX_AT_GUN_MS)
268
+ side = lastBoatSide.c;
269
+ startedClean = side != null && side >= 0;
270
+ }
271
+ }
272
+
171
273
  /**
172
274
  * The VMG the plugin divides a leg by: what it publishes if it does, otherwise
173
275
  * the collected best or whatever the boat is achieving now, whichever is larger,
@@ -196,8 +298,19 @@
196
298
  + ' ' + at(h * 0.55, -b * 0.42) + ' ' + at(h, 0) + ' Z';
197
299
  }
198
300
 
199
- /** One leg of the approach, as a dimension line with end ticks and a label. */
200
- function dimension(parts, x1, y1, x2, y2, horizontal, label, title, W) {
301
+ /** Whether a leg this long has room for its label in a break in the rule. */
302
+ function legRoomy(length, label) {
303
+ return length > label.length * LEG_FONT * 0.6 + 16;
304
+ }
305
+
306
+ /**
307
+ * One leg of the approach, as a dimension line with end ticks and a label.
308
+ *
309
+ * @param side Which side of the rule the label goes: horizontal -1 above, 1 below;
310
+ * vertical -1 left, 1 right. Left out, a horizontal label goes above a roomy leg
311
+ * and below a short one, and a vertical label to the right.
312
+ */
313
+ function buildLeg(x1, y1, x2, y2, horizontal, label, title, W, side) {
201
314
  const tick = 5;
202
315
  const ticks = horizontal
203
316
  ? 'M' + x1 + ',' + (y1 - tick) + ' L' + x1 + ',' + (y1 + tick)
@@ -205,47 +318,156 @@
205
318
  : 'M' + (x1 - tick) + ',' + y1 + ' L' + (x1 + tick) + ',' + y1
206
319
  + ' M' + (x2 - tick) + ',' + y2 + ' L' + (x2 + tick) + ',' + y2;
207
320
 
208
- // A dimension's value sits in a break in the rule, but a short leg has no room
209
- // for one - and the along-line leg is often very short.
210
- const length = Math.hypot(x2 - x1, y2 - y1);
211
- const roomy = length > label.length * 9 + 16;
212
- let lx, ly, anchor;
321
+ const F = LEG_FONT;
322
+ const roomy = legRoomy(Math.hypot(x2 - x1, y2 - y1), label);
323
+ let labelX, labelY, anchor;
213
324
  if (horizontal) {
214
- if (roomy) { lx = (x1 + x2) / 2; ly = (y1 + y2) / 2 - 6; anchor = 'middle'; }
325
+ const below = (side != null ? side : (roomy ? -1 : 1)) > 0;
326
+ labelY = (y1 + y2) / 2 + (below ? F * 1.13 : -F * 0.4);
327
+ if (roomy) { labelX = (x1 + x2) / 2; anchor = 'middle'; }
215
328
  else {
216
- // Out past the corner and below the rule: the boat is on this leg's
217
- // other end and the across leg runs up from the corner.
218
- lx = x1 < x2 ? x2 + 8 : x2 - 8;
219
- ly = (y1 + y2) / 2 + 17;
329
+ // Out past the corner: the boat sits on this leg's other end.
330
+ labelX = x1 < x2 ? x2 + F * 0.53 : x2 - F * 0.53;
220
331
  anchor = x1 < x2 ? 'start' : 'end';
221
332
  }
222
333
  } else {
223
- lx = x1 + 8; anchor = 'start';
224
- ly = roomy ? (y1 + y2) / 2 + 5 : (y1 < y2 ? y1 - 8 : y1 + 14);
334
+ const right = (side != null ? side : 1) > 0;
335
+ labelX = right ? x1 + F * 0.53 : x1 - F * 0.53;
336
+ anchor = right ? 'start' : 'end';
337
+ labelY = roomy ? (y1 + y2) / 2 + F * 0.33
338
+ : (y1 < y2 ? y1 - F * 0.53 : y1 + F * 0.93);
225
339
  }
226
- const width = label.length * 9;
340
+ // Held inside the drawing: a label pushed outside a short leg can run off it.
341
+ const width = label.length * F * 0.6;
227
342
  const lead = anchor === 'end' ? width : anchor === 'middle' ? width / 2 : 0;
228
343
  const trail = anchor === 'start' ? width : anchor === 'middle' ? width / 2 : 0;
229
- lx = Math.min(Math.max(lx, lead + 3), Math.max(W - trail - 3, lead + 3));
230
-
231
- parts.push('<g><title>' + esc(title) + '</title>'
232
- + '<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2
233
- + '" stroke="' + COLOUR.dimension + '" stroke-width="1.5" stroke-opacity="0.9"/>'
234
- + '<path d="' + ticks + '" stroke="' + COLOUR.dimension + '" stroke-width="1.5" fill="none"/>'
235
- + '<text x="' + lx + '" y="' + ly + '" text-anchor="' + anchor + '" font-size="15"'
236
- + ' font-weight="bold" fill="' + COLOUR.dimension + '" stroke="' + COLOUR.halo
237
- + '" stroke-width="4" paint-order="stroke">' + esc(label) + '</text></g>');
344
+ labelX = Math.min(Math.max(labelX, lead + 3), Math.max(W - trail - 3, lead + 3));
345
+
346
+ return {x1: x1, y1: y1, x2: x2, y2: y2, ticks: ticks, label: label, title: title,
347
+ labelX: labelX, labelY: labelY, anchor: anchor, leading: false};
348
+ }
349
+
350
+ function legLabelBox(leg) {
351
+ const width = leg.label.length * LEG_FONT * 0.6;
352
+ const left = leg.anchor === 'start' ? leg.labelX
353
+ : leg.anchor === 'end' ? leg.labelX - width : leg.labelX - width / 2;
354
+ return {left: left, right: left + width,
355
+ top: leg.labelY - LEG_FONT * 0.75, bottom: leg.labelY + LEG_FONT * 0.25};
356
+ }
357
+
358
+ /**
359
+ * The last word on the two labels not overlapping: when holding a label inside the
360
+ * drawing has pushed it back across the other, the across label slides on along
361
+ * its own leg, towards the line, until it is clear.
362
+ */
363
+ function separateLegLabels(zone, across, towardsLine) {
364
+ const a = legLabelBox(zone), b = legLabelBox(across);
365
+ const gap = 2;
366
+ if (a.right + gap <= b.left || b.right + gap <= a.left
367
+ || a.bottom + gap <= b.top || b.bottom + gap <= a.top) return;
368
+ const shift = towardsLine < 0 ? b.bottom - a.top + gap : a.bottom - b.top + gap;
369
+ across.labelY += (towardsLine < 0 ? -1 : 1) * shift;
370
+ }
371
+
372
+ function drawLeg(parts, leg) {
373
+ const colour = leg.leading ? COLOUR.zoneLeg : COLOUR.dimension;
374
+ parts.push('<g><title>' + esc(leg.title) + '</title>'
375
+ + '<line x1="' + leg.x1 + '" y1="' + leg.y1 + '" x2="' + leg.x2 + '" y2="' + leg.y2
376
+ + '" stroke="' + colour + '" stroke-width="1.5" stroke-opacity="0.9"/>'
377
+ + '<path d="' + leg.ticks + '" stroke="' + colour + '" stroke-width="1.5" fill="none"/>'
378
+ + '<text x="' + leg.labelX + '" y="' + leg.labelY + '" text-anchor="' + leg.anchor
379
+ + '" font-size="' + LEG_FONT + '" font-weight="bold" fill="' + colour
380
+ + '" stroke="' + COLOUR.halo + '" stroke-width="4" paint-order="stroke">'
381
+ + esc(leg.label) + '</text></g>');
382
+ }
383
+
384
+ /**
385
+ * The approach the time to line is computed over: along the line into the start
386
+ * zone, then across to the line, as dimension lines - a distance to be made good,
387
+ * not a course to steer - plus where the boat reaches along them at the gun.
388
+ */
389
+ function buildApproach(nav, geo, sx, sy, scale, W) {
390
+ const boat = geo.boat;
391
+ const L = geo.length, a = boat.a, c = boat.c;
392
+ const ocs = c < 0, across = Math.abs(c);
393
+
394
+ // How far past an end the boat lies, and which way it must run to get back.
395
+ let overshoot = 0, beyondPort = false;
396
+ if (a > L) { overshoot = a - L; beyondPort = true; }
397
+ else if (a < 0) { overshoot = -a; }
398
+ // Inside the 45 degree wedge the zone leg vanishes: the boat closes straight
399
+ // across. Outside it, the corner sits on the wedge.
400
+ const toZone = Math.max(0, overshoot - across);
401
+ const cornerA = toZone > 0 ? (beyondPort ? L + across : -across) : a;
402
+
403
+ const parallelName = beyondPort ? 'toStbEnd' : 'toPortEnd';
404
+ const normalName = ocs ? 'fromCourseSide' : 'toCourseSide';
405
+ const parallel = effectiveVmg(nav, nav.effVmgAlongLine, parallelName, geo.bearing);
406
+ const normal = effectiveVmg(nav, nav.effVmgToLine, normalName, geo.bearing);
407
+
408
+ // The two labels meet at the corner, so each goes on the side of its own leg
409
+ // facing away from the other. When both legs are short both labels want the
410
+ // same outside quarter, so the zone label moves round to the line's side.
411
+ const zoneLabel = Math.round(toZone) + 'm';
412
+ const acrossLabel = Math.round(across) + 'm';
413
+ const towardsBoat = toZone > 0 ? Math.sign(sx(a) - sx(cornerA)) : 0;
414
+ const towardsLine = across > 0 ? Math.sign(sy(0) - sy(c)) : 0;
415
+ const zoneShort = !legRoomy(Math.abs(sx(a) - sx(cornerA)), zoneLabel);
416
+ const acrossShort = !legRoomy(Math.abs(sy(0) - sy(c)), acrossLabel);
417
+ const zoneSide = towardsLine === 0 ? undefined
418
+ : zoneShort && acrossShort ? towardsLine : -towardsLine;
419
+ const acrossSide = towardsBoat === 0 ? undefined : -towardsBoat;
420
+
421
+ const legs = [];
422
+ if (toZone > 0) {
423
+ legs.push(buildLeg(sx(a), sy(c), sx(cornerA), sy(c), true, zoneLabel,
424
+ 'Along the line to the start zone', W, zoneSide));
425
+ }
426
+ // Only a leg with another after it leads; it is drawn in its own shade.
427
+ if (toZone > 0 && across > 0) legs[0].leading = true;
428
+ if (across > 0) {
429
+ legs.push(buildLeg(sx(cornerA), sy(c), sx(cornerA), sy(0), false, acrossLabel,
430
+ 'Across to the line', W, acrossSide));
431
+ }
432
+ if (legs.length === 2) separateLegLabels(legs[0], legs[1], towardsLine);
433
+
434
+ // Where the boat reaches at the gun, walking those legs at those VMGs.
435
+ // A leg with no VMG behind it costs no time, as computeTimeToLine has it.
436
+ let gun = null;
437
+ const tts = nav.timeToStart;
438
+ if (nav.timerRunning && tts != null && tts > 0) {
439
+ const alongTime = parallel > 0 ? toZone / parallel : 0;
440
+ let gunA, gunC, ok = true;
441
+ if (tts <= alongTime) {
442
+ const run = parallel * tts;
443
+ gunA = beyondPort ? a - run : a + run;
444
+ gunC = c;
445
+ } else if (normal > 0) {
446
+ const run = Math.min(normal * (tts - alongTime),
447
+ across + 400 / Math.max(scale, 1e-6));
448
+ gunA = cornerA;
449
+ gunC = ocs ? c + run : c - run;
450
+ } else { ok = false; }
451
+ if (ok) gun = {x: sx(gunA), y: sy(gunC)};
452
+ }
453
+ return {legs: legs, gun: gun};
238
454
  }
239
455
 
240
456
  function render(nav) {
241
457
  if (!svg) return;
242
458
  const box = svg.getBoundingClientRect();
243
459
  const aspect = box.height > 0 ? box.width / box.height : 400 / VB_HEIGHT;
460
+ // Clamped so an extreme aspect cannot squash the drawing into a sliver.
244
461
  const W = Math.round(VB_HEIGHT * Math.min(Math.max(aspect, 0.6), 4));
245
462
  svg.setAttribute('viewBox', '0 0 ' + W + ' ' + VB_HEIGHT);
246
463
 
247
- const geo = lineGeometry(nav.port, nav.stb, nav.position);
464
+ // The plugin nulls the length when the line is lost, while the end positions
465
+ // keep their last values, so the length is what says there is a line.
466
+ const geo = nav.lineLength == null ? null
467
+ : lineGeometry(nav.port, nav.stb, nav.position);
468
+ watchGun(nav, geo);
248
469
  if (!geo) {
470
+ frame = frameKey = frameIdeal = null;
249
471
  svg.innerHTML = '<text x="' + (W / 2) + '" y="' + (VB_HEIGHT / 2)
250
472
  + '" text-anchor="middle" fill="' + COLOUR.guide
251
473
  + '" font-size="16">No start line set</text>';
@@ -257,6 +479,7 @@
257
479
  const sx = function (a) { return W / 2 - (a - f.midA) * scale; };
258
480
  const sy = function (c) { return (bandTop() + bandBottom()) / 2 + (c - f.midC) * scale; };
259
481
  const portX = sx(geo.length), stbX = sx(0), lineY = sy(0);
482
+ const boat = geo.boat;
260
483
  const parts = [];
261
484
 
262
485
  // The wind, against the line's own orientation: with the line drawn flat, the
@@ -271,145 +494,106 @@
271
494
  const degrees = ((nav.twd * 180 / Math.PI) % 360 + 360) % 360;
272
495
  const off = (((nav.twd * 180 / Math.PI) - geo.bearing) % 360 + 360) % 360;
273
496
  parts.push('<polygon points="' + [at(30, 0), at(4, 16), at(4, 6), at(-30, 6),
274
- at(-30, -6), at(4, -6), at(4, -16)].join(' ') + '" fill="' + COLOUR.label
497
+ at(-30, -6), at(4, -6), at(4, -16)].join(' ') + '" fill="' + COLOUR.wind
275
498
  + '" fill-opacity="0.65"><title>Wind from '
276
499
  + degrees.toFixed(0).padStart(3, '0') + '°T, '
277
500
  + (off > 180 ? 360 - off : off).toFixed(0) + '° to the line</title></polygon>');
278
501
  }
279
502
 
280
- // The start zone: the line's extensions and the 45 degree wedge off each end.
281
- // They decide where the approach turns, so they sit under it.
282
- const reach = (W + VB_HEIGHT) / Math.max(scale, 1e-6);
283
- const guide = function (a1, c1, a2, c2) {
284
- parts.push('<line x1="' + sx(a1) + '" y1="' + sy(c1) + '" x2="' + sx(a2)
285
- + '" y2="' + sy(c2) + '" stroke="' + COLOUR.guide
286
- + '" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="4 5"/>');
287
- };
288
- guide(0, 0, -reach, 0);
289
- guide(geo.length, 0, geo.length + reach, 0);
290
- guide(0, 0, -reach, reach);
291
- guide(0, 0, -reach, -reach);
292
- guide(geo.length, 0, geo.length + reach, reach);
293
- guide(geo.length, 0, geo.length + reach, -reach);
503
+ // The length and heading label first of all: it is context for the line, not
504
+ // part of the approach, so anything that overlaps it wins.
505
+ const length = geo.length;
506
+ const lineBearing = nav.lineBearing != null
507
+ ? (nav.lineBearing * 180 / Math.PI + 360) % 360 : geo.bearing;
508
+ // The heading worth reporting is the one sailed to cross the line: ninety
509
+ // degrees clockwise of the line bearing, always straight up in this view -
510
+ // which the trailing arrow says, so no separate arrowhead is needed.
511
+ const startBearing = (lineBearing + 90) % 360;
512
+ const label = Math.round(length) + 'm · '
513
+ + startBearing.toFixed(0).padStart(3, '0') + '°T↑';
514
+ const font = Math.min(LABEL_FONT, (W - 8) / (label.length * 0.51));
515
+ const half = label.length * font * 0.51 / 2;
516
+ // Centred on the line but held inside the drawing: the line can sit well off
517
+ // centre, and the label is often wider than the line itself.
518
+ const labelX = Math.min(Math.max((portX + stbX) / 2, half + 4), W - half - 4);
519
+ parts.push('<text x="' + labelX + '" y="' + (lineY - font * 0.5) + '" text-anchor="middle"'
520
+ + ' font-size="' + font + '" fill="' + COLOUR.label + '" stroke="' + COLOUR.halo
521
+ + '" stroke-width="8" paint-order="stroke">' + esc(label) + '</text>');
294
522
 
295
- const boat = geo.boat;
523
+ let approach = null;
296
524
  if (boat) {
525
+ // The start zone: the line's extensions and the 45 degree wedge off each
526
+ // end. They decide where the approach turns, so they sit under it.
527
+ const reach = (W + VB_HEIGHT) / Math.max(scale, 1e-6);
528
+ const guide = function (a1, c1, a2, c2) {
529
+ parts.push('<line x1="' + sx(a1) + '" y1="' + sy(c1) + '" x2="' + sx(a2)
530
+ + '" y2="' + sy(c2) + '" stroke="' + COLOUR.guide
531
+ + '" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="4 5"/>');
532
+ };
533
+ guide(0, 0, -reach, 0);
534
+ guide(geo.length, 0, geo.length + reach, 0);
535
+ guide(0, 0, -reach, reach);
536
+ guide(0, 0, -reach, -reach);
537
+ guide(geo.length, 0, geo.length + reach, reach);
538
+ guide(geo.length, 0, geo.length + reach, -reach);
539
+
540
+ approach = buildApproach(nav, geo, sx, sy, scale, W);
541
+
297
542
  const bx = sx(boat.a), by = sy(boat.c);
298
543
  const ocs = boat.c < 0;
299
- const L = geo.length, a = boat.a, c = boat.c, across = Math.abs(c);
300
-
301
- // How far past an end the boat lies, and which way it must run to get back.
302
- let overshoot = 0, beyondPort = false;
303
- if (a > L) { overshoot = a - L; beyondPort = true; }
304
- else if (a < 0) { overshoot = -a; }
305
- // Inside the 45 degree wedge the zone leg vanishes: the boat closes
306
- // straight across. Outside it, the corner sits on the wedge.
307
- const toZone = Math.max(0, overshoot - across);
308
- const cornerA = toZone > 0 ? (beyondPort ? L + across : -across) : a;
309
-
310
- const parallelName = beyondPort ? 'toStbEnd' : 'toPortEnd';
311
- const normalName = ocs ? 'fromCourseSide' : 'toCourseSide';
312
- const parallel = effectiveVmg(nav, nav.effVmgAlongLine, parallelName, geo.bearing);
313
- const normal = effectiveVmg(nav, nav.effVmgToLine, normalName, geo.bearing);
314
-
315
- // The approach the time to line is computed over. Nobody sails parallel to
316
- // the line then turns ninety degrees, so it is drawn as dimension lines:
317
- // a distance to be made good, not a course to steer.
318
- if (toZone > 0) {
319
- dimension(parts, sx(a), sy(c), sx(cornerA), sy(c), true,
320
- Math.round(toZone) + 'm', 'Along the line to the start zone', W);
321
- }
322
- if (across > 0) {
323
- dimension(parts, sx(cornerA), sy(c), sx(cornerA), sy(0), false,
324
- Math.round(across) + 'm', 'Across to the line', W);
325
- }
326
-
327
- // Where the boat reaches at the gun, walking those legs at those VMGs.
328
- // A leg with no VMG behind it costs no time, as computeTimeToLine has it.
329
- if (nav.timerRunning && nav.timeToStart > 0) {
330
- const alongTime = parallel > 0 ? toZone / parallel : 0;
331
- let gunA, gunC, ok = true;
332
- if (nav.timeToStart <= alongTime) {
333
- const run = parallel * nav.timeToStart;
334
- gunA = beyondPort ? a - run : a + run;
335
- gunC = c;
336
- } else if (normal > 0) {
337
- const run = Math.min(normal * (nav.timeToStart - alongTime),
338
- across + 400 / Math.max(scale, 1e-6));
339
- gunA = cornerA;
340
- gunC = ocs ? c + run : c - run;
341
- } else { ok = false; }
342
- if (ok) {
343
- parts.push('<circle cx="' + sx(gunA).toFixed(1) + '" cy="' + sy(gunC).toFixed(1)
344
- + '" r="5" fill="' + COLOUR.halo + '" stroke="' + COLOUR.dimension
345
- + '" stroke-width="2.5"><title>Where you reach at the gun,'
346
- + ' sailing these legs at these VMGs</title></circle>');
347
- }
348
- }
349
544
 
350
545
  // The current course, run out to where the boat gets to at the gun.
351
546
  let courseLabel = null;
352
547
  if (nav.cog != null) {
353
548
  const v = screenVector(nav.cog, geo.bearing);
354
549
  const running = nav.timerRunning && nav.timeToStart > 0;
355
- let len = 40;
356
- if (running && nav.sog != null) {
357
- // Clamped past the corner of the viewBox: a long countdown runs
358
- // well off the drawing and is clipped there anyway.
359
- len = Math.min(nav.sog * nav.timeToStart * scale, 600);
360
- courseLabel = 'Current cog/sog to start';
550
+ let len = null;
551
+ if (running) {
552
+ if (nav.sog != null) {
553
+ // Clamped past the corner of the viewBox: a long countdown
554
+ // runs well off the drawing and is clipped there anyway.
555
+ len = Math.min(nav.sog * nav.timeToStart * scale, 600);
556
+ courseLabel = LEGEND_CURRENT;
557
+ }
361
558
  } else {
362
- courseLabel = 'Current course over ground (no timer running)';
559
+ len = 40;
560
+ courseLabel = LEGEND_STUB;
561
+ }
562
+ if (len != null) {
563
+ parts.push('<line x1="' + bx.toFixed(1) + '" y1="' + by.toFixed(1)
564
+ + '" x2="' + (bx + v.x * len).toFixed(1) + '" y2="' + (by + v.y * len).toFixed(1)
565
+ + '" stroke="' + COLOUR.boat + '" stroke-width="6" stroke-opacity="0.55"'
566
+ + ' stroke-linecap="round"><title>' + esc(courseLabel) + '</title></line>');
363
567
  }
364
- parts.push('<line x1="' + bx.toFixed(1) + '" y1="' + by.toFixed(1)
365
- + '" x2="' + (bx + v.x * len).toFixed(1) + '" y2="' + (by + v.y * len).toFixed(1)
366
- + '" stroke="' + COLOUR.boat + '" stroke-width="6" stroke-opacity="0.55"'
367
- + ' stroke-linecap="round"><title>' + esc(courseLabel) + '</title></line>');
368
568
  }
369
569
 
370
- // The hull at true scale, so it can be measured against the line, banded
371
- // out to a scaled-up copy so it stays visible when the drawing zooms out:
372
- // twice the hull at five boat lengths off.
570
+ // The hull, solid at true scale with a minimum size, hung aft of the fix:
571
+ // the fix is the bow, which is what decides whether you are over.
373
572
  const heading = nav.headingTrue != null ? nav.headingTrue : nav.cog;
374
573
  const hv = heading != null ? screenVector(heading, geo.bearing) : {x: 0, y: -1};
375
574
  const px = -hv.y, py = hv.x;
575
+ const hull = Math.max(boatReach(nav) * scale, MIN_HULL_DISPLAY_UNITS);
576
+ const cx = bx - hv.x * hull / 2, cy = by - hv.y * hull / 2;
376
577
  const at = function (fwd, stbd) {
377
- return (bx + hv.x * fwd + px * stbd).toFixed(1) + ','
378
- + (by + hv.y * fwd + py * stbd).toFixed(1);
578
+ return (cx + hv.x * fwd + px * stbd).toFixed(1) + ','
579
+ + (cy + hv.y * fwd + py * stbd).toFixed(1);
379
580
  };
380
- const hull = Math.min(Math.max((nav.boatLength || DEFAULT_BOAT_METRES) * scale, 6), 200);
381
- const outer = Math.max(hull + Math.abs(by - lineY) / 5, hull + 9);
382
581
  const tip = ['SOG ' + formatKnots(nav.sog) + ' COG ' + formatBearing(nav.cog)];
383
582
  if (courseLabel) tip.push(courseLabel);
384
- parts.push('<path d="' + hullPath(at, outer) + ' ' + hullPath(at, hull)
385
- + '" fill="' + (ocs ? COLOUR.ocs : COLOUR.boat) + '" fill-rule="evenodd">'
583
+ parts.push('<path d="' + hullPath(at, hull) + '" fill="'
584
+ + (ocs ? COLOUR.ocs : COLOUR.boat) + '">'
386
585
  + '<title>' + esc(tip.join('\n')) + '</title></path>');
387
586
  }
388
587
 
389
- // The line, its label and its ends last, so nothing closing on it breaks it up.
588
+ // The line itself, over everything closing on it: red when over, green once a
589
+ // clean start has been made.
590
+ const lineColour = startedClean ? COLOUR.lineStarted
591
+ : boat && boat.c < 0 ? COLOUR.lineOcs : COLOUR.line;
390
592
  parts.push('<line x1="' + portX + '" y1="' + lineY + '" x2="' + stbX + '" y2="' + lineY
391
- + '" stroke="' + COLOUR.line + '" stroke-width="4" stroke-dasharray="10 8"/>');
392
-
393
- const length = nav.lineLength != null ? nav.lineLength : geo.length;
394
- const lineBearing = nav.lineBearing != null
395
- ? (nav.lineBearing * 180 / Math.PI + 360) % 360 : geo.bearing;
396
- // The heading worth reporting is the one sailed to cross the line: ninety
397
- // degrees clockwise of the line bearing, always straight up in this view -
398
- // which the trailing arrow says, so no separate arrowhead is needed.
399
- const startBearing = (lineBearing + 90) % 360;
400
- const label = Math.round(length) + 'm · '
401
- + startBearing.toFixed(0).padStart(3, '0') + '°T↑';
402
- const font = Math.min(LABEL_FONT, (W - 8) / (label.length * 0.51));
403
- const half = label.length * font * 0.51 / 2;
404
- // Centred on the line but held inside the drawing: the line can sit well off
405
- // centre, and the label is often wider than the line itself.
406
- const labelX = Math.min(Math.max((portX + stbX) / 2, half + 4), W - half - 4);
407
- parts.push('<text x="' + labelX + '" y="' + (lineY - font * 0.5) + '" text-anchor="middle"'
408
- + ' font-size="' + font + '" fill="' + COLOUR.label + '" stroke="' + COLOUR.halo
409
- + '" stroke-width="8" paint-order="stroke">' + esc(label) + '</text>');
593
+ + '" stroke="' + lineColour + '" stroke-width="4" stroke-dasharray="10 8"/>');
410
594
 
411
595
  // Both ends stand for a 10m object, exaggerated so they stay legible.
412
- const endSize = Math.min(Math.max(END_METRES * scale * END_EXAGGERATION, 12), 40);
596
+ const endSize = Math.min(Math.max(END_METRES * scale * END_EXAGGERATION, 12), END_SIZE_MAX);
413
597
  const k = endSize / 24;
414
598
  parts.push('<circle cx="' + portX + '" cy="' + lineY + '" r="' + (endSize * 0.29)
415
599
  + '" fill="' + COLOUR.port + '" stroke="' + COLOUR.halo + '"/>');
@@ -420,6 +604,18 @@
420
604
  parts.push('<rect x="' + (stbX - 3 * k) + '" y="' + (lineY - 10 * k) + '" width="' + (9 * k)
421
605
  + '" height="' + (6 * k) + '" fill="' + COLOUR.stb + '" stroke="' + COLOUR.halo + '"/>');
422
606
 
607
+ // The approach legs and the gun mark last, so the distances stay readable over
608
+ // the boat, which is exactly where they run as it closes on the line.
609
+ if (approach) {
610
+ approach.legs.forEach(function (leg) { drawLeg(parts, leg); });
611
+ if (approach.gun) {
612
+ parts.push('<circle cx="' + approach.gun.x.toFixed(1) + '" cy="'
613
+ + approach.gun.y.toFixed(1) + '" r="5" fill="' + COLOUR.halo
614
+ + '" stroke="' + COLOUR.dimension + '" stroke-width="2.5"><title>Where you'
615
+ + ' reach at the gun, sailing these legs at these VMGs</title></circle>');
616
+ }
617
+ }
618
+
423
619
  svg.innerHTML = parts.join('');
424
620
  }
425
621
 
package/racer-webapp.png DELETED
Binary file
File without changes
File without changes