signalk-racer 1.3.1 → 1.3.3
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 +93 -20
- package/ReleaseNotes.md +7 -0
- package/images/racer-skip-adjust-line.png +0 -0
- package/images/racer-skip-approach.png +0 -0
- package/images/racer-skip-final.png +0 -0
- package/images/racer-skip-ocs.png +0 -0
- package/images/racer-skip-set-line.png +0 -0
- package/images/racer-skip-started.png +0 -0
- package/images/racer-skip-vmgs.png +0 -0
- package/images/racer-skip.png +0 -0
- package/images/racer-timer-adjust.png +0 -0
- package/images/racer-timer-set.png +0 -0
- package/images/racer-timer-start.png +0 -0
- package/images/racer-timer-sync.png +0 -0
- package/images/racer-webapp.png +0 -0
- package/index.js +14 -0
- package/package.json +1 -1
- package/public/index.html +12 -2
- package/public/line-view.js +357 -161
- package/racer-webapp.png +0 -0
- /package/{racer-kip.png → images/racer-kip.png} +0 -0
- /package/{racer-line.png → images/racer-line.png} +0 -0
package/README.md
CHANGED
|
@@ -36,14 +36,20 @@ 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
|
-

|
|
43
|
+
*Skip start line view and timer widgets besides Freeboard-SK*
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+

|
|
46
|
+
*KIP racer timer and line widgets beside Freeboard-SK showing the next leg*
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
*The racer webapp: line adjustment, the approach to the line, best VMGs and the start timer*
|
|
45
52
|
|
|
46
|
-

|
|
47
53
|
|
|
48
54
|
---
|
|
49
55
|
|
|
@@ -70,15 +76,16 @@ You can configure the plugin via the Signal K web interface or by editing `setti
|
|
|
70
76
|
|
|
71
77
|
### Waypoint selection (`navigation.racing.startLinePort` and `navigation.racing.startLineStb`)
|
|
72
78
|
|
|
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.
|
|
79
|
+
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
80
|
|
|
75
81
|
### Bow position
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
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
84
|
|
|
79
85
|
### Distance to Start Line (`navigation.racing.distanceStartline`)
|
|
80
86
|
|
|
81
|
-

|
|
87
|
+

|
|
88
|
+
*The start zone: inside it the distance is straight across to the line, outside it the boat must first reach the zone*
|
|
82
89
|
|
|
83
90
|
- Computes the distance from the **bow** of the boat to the configured start line.
|
|
84
91
|
- 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 +159,22 @@ Runs along the boat's present `navigation.courseOverGroundTrue`. While the start
|
|
|
152
159
|
down, its length is `SOG × timeToStart`: the distance the boat will cover before the gun if nothing
|
|
153
160
|
changes. Its tip is therefore **where the boat will be when the start fires**:
|
|
154
161
|
|
|
155
|
-
- tip short of the line — the boat
|
|
162
|
+
- tip short of the line — the boat will be late at current speed and heading.
|
|
156
163
|
- tip on the line — a perfect start
|
|
157
|
-
- tip beyond the line — the boat is early and will be OCS
|
|
164
|
+
- tip beyond the line — the boat is early and will be OCS unless speed reduces or heading changes.
|
|
158
165
|
|
|
159
166
|
With no timer running there is nothing to project against, so it degrades to a short fixed-length
|
|
160
167
|
stub showing course only.
|
|
161
168
|
|
|
169
|
+
#### VMG projection — "Best VMG to start"
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
See the Skip widgets below for a full description of the line visualisation.
|
|
176
|
+
|
|
177
|
+
|
|
162
178
|
---
|
|
163
179
|
|
|
164
180
|
## 🌐 API Access
|
|
@@ -233,16 +249,75 @@ Used to **start**, **sync**, **reset**, or **set a fixed start time**.
|
|
|
233
249
|
```
|
|
234
250
|
---
|
|
235
251
|
|
|
236
|
-
## 🧩
|
|
237
|
-
|
|
252
|
+
## 🧩 Skip Widgets
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+

|
|
256
|
+
*Approaching the start zone*
|
|
257
|
+
|
|
258
|
+
The `skip-line-view` widget is a graphical alternative to the `racer-line` widget, both have similar features.
|
|
259
|
+
The screenshot above shows the Skip `racer-line-view` widget with:
|
|
260
|
+
+ Line always displayed horizontally, with extension lines and 45-degree approaches
|
|
261
|
+
+ The current True Wind Direction (TWD) is shown in the top right
|
|
262
|
+
+ The distance the boat needs to cover to both reach the start zone (72m) and to reach the line (21m) is shown.
|
|
263
|
+
+ The blue projection line showing where the boat will be at the start time on the current course and speed (COG/SOG)
|
|
264
|
+
+ The best VMG dot showing where the boat could be if it sails to the zone and then the line at its best VMG.
|
|
265
|
+
+ The time to line (0:43) is shown in seconds for the boat's best VMG.
|
|
266
|
+
+ The time to burn (0:12) is shown in seconds for the time the boat must waste to arrive on the line on time.
|
|
267
|
+
+ The '...' mode selector to access other views/configurations.
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+

|
|
271
|
+
*Final Approach to the start line*
|
|
272
|
+
|
|
273
|
+
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.
|
|
274
|
+
|
|
275
|
+

|
|
276
|
+
*Good start line goes Green*
|
|
277
|
+
|
|
278
|
+
On a good start, the line goes green and stays green until the timer is reset.
|
|
279
|
+
|
|
280
|
+

|
|
281
|
+
*OCS start line goes Red*
|
|
282
|
+
|
|
283
|
+
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.
|
|
238
284
|
|
|
239
|
-
|
|
285
|
+

|
|
286
|
+
*Set the line*
|
|
287
|
+
|
|
288
|
+
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.
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+

|
|
292
|
+
*Adjust the line*
|
|
293
|
+
|
|
294
|
+
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.
|
|
295
|
+
|
|
296
|
+

|
|
297
|
+
*Best VMGs*
|
|
298
|
+
|
|
299
|
+
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.
|
|
300
|
+
|
|
301
|
+

|
|
302
|
+
*Start Timer*
|
|
303
|
+
|
|
304
|
+
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.
|
|
305
|
+
|
|
306
|
+

|
|
307
|
+
*Adjust Timer*
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+

|
|
312
|
+
*Set Timer*
|
|
313
|
+
|
|
314
|
+
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.
|
|
315
|
+
|
|
316
|
+

|
|
317
|
+
*Sync Timer*
|
|
318
|
+
|
|
319
|
+
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
320
|
|
|
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
321
|
|
|
247
322
|
### Libraries
|
|
248
323
|
- `geolib`: for distance and bearing calculations.
|
|
@@ -251,8 +326,7 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
|
|
|
251
326
|
|
|
252
327
|
## 🧪 Future Plans
|
|
253
328
|
|
|
254
|
-
-
|
|
255
|
-
- **Time to burn**
|
|
329
|
+
- **Polars** - include polars in the effective VMG calculations
|
|
256
330
|
- **Laylines** and distance/time to them
|
|
257
331
|
|
|
258
332
|
---
|
|
@@ -261,7 +335,6 @@ KIP components for `racer-timer` and `racer-line` have been developed and are av
|
|
|
261
335
|
Bug reports and suggestions are welcome at
|
|
262
336
|
[https://github.com/gregw/signalk-racer](https://github.com/gregw/signalk-racer)
|
|
263
337
|
|
|
264
|
-
|
|
265
338
|
---
|
|
266
339
|
## Development
|
|
267
340
|
|
package/ReleaseNotes.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 1.3.3 Updated for Skip widgets
|
|
2
|
+
+ Updated the line visualisation to match the latest skip widget.
|
|
3
|
+
+ Update the documentation to reflect the latest skip widget.
|
|
4
|
+
|
|
5
|
+
## 1.3.2 Distance to length
|
|
6
|
+
+ Set the metadata for distance to line as a length rather than as a distance.
|
|
7
|
+
|
|
1
8
|
## 1.3.1 Start line approach
|
|
2
9
|
+ Fixed the distance to the start zone being measured against a 54.7 degree wedge while
|
|
3
10
|
the zone itself was tested at 45 degrees, so `distanceStartline` and `timeToLine`
|
|
Binary file
|
|
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
|
@@ -145,11 +145,23 @@ module.exports = (app) => {
|
|
|
145
145
|
"value": {
|
|
146
146
|
"type": "number",
|
|
147
147
|
"units": "m",
|
|
148
|
+
"displayUnits": { "category": "length" },
|
|
148
149
|
"description": "Length of the start line",
|
|
149
150
|
"displayName": "Length of the start line",
|
|
150
151
|
"shortName": "SLL"
|
|
151
152
|
}
|
|
152
153
|
},
|
|
154
|
+
{
|
|
155
|
+
"path": "navigation.racing.distanceStartline",
|
|
156
|
+
"value": {
|
|
157
|
+
"type": "number",
|
|
158
|
+
"units": "m",
|
|
159
|
+
"displayUnits": { "category": "length" },
|
|
160
|
+
"description": "The minimum distance from the bow to the start line, via perpendicular approach",
|
|
161
|
+
"displayName": "Distance to the start line",
|
|
162
|
+
"shortName": "DTL"
|
|
163
|
+
}
|
|
164
|
+
},
|
|
153
165
|
{
|
|
154
166
|
"path": "navigation.racing.startLineBearing",
|
|
155
167
|
"value": {
|
|
@@ -225,6 +237,7 @@ module.exports = (app) => {
|
|
|
225
237
|
"value": {
|
|
226
238
|
"type": "number",
|
|
227
239
|
"units": "m",
|
|
240
|
+
"displayUnits": { "category": "length" },
|
|
228
241
|
"description": "Bias of the start line for the starboard end",
|
|
229
242
|
"displayName": "Bias of the start line for the starboard end",
|
|
230
243
|
"shortName": "Bias"
|
|
@@ -1271,6 +1284,7 @@ module.exports = (app) => {
|
|
|
1271
1284
|
app.setPluginStatus?.(`signalk-racer ${pluginVersion} started`);
|
|
1272
1285
|
app.debug(`signalk-racer ${pluginVersion} loaded from ${__dirname}`);
|
|
1273
1286
|
|
|
1287
|
+
// These offsets are no longer sent, with the position itself being corrected.
|
|
1274
1288
|
let fromBow = app.getSelfPath('sensors.gps.fromBow');
|
|
1275
1289
|
let fromCenter = app.getSelfPath('sensors.gps.fromCenter');
|
|
1276
1290
|
state.options = options;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-racer",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
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
|
@@ -71,7 +71,11 @@
|
|
|
71
71
|
gap: 0 0.5em;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
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"
|
|
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
|
|
package/public/line-view.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The start line drawing, shared in substance with the
|
|
2
|
+
* The start line drawing, shared in substance with the Skip racer-line-view widget.
|
|
3
3
|
*
|
|
4
|
-
* Ported by hand from the
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
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 ?
|
|
112
|
-
|
|
113
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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 <=
|
|
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) >
|
|
229
|
+
|| frameDrift(frameIdeal || frame, ideal, geo, W) > tolerance
|
|
163
230
|
|| !lineFitsIn(frame)
|
|
164
231
|
|| !boatFitsIn(nav, frame, geo, W)) {
|
|
165
|
-
|
|
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
|
-
/**
|
|
200
|
-
function
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
281
|
-
//
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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 =
|
|
356
|
-
if (running
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
371
|
-
//
|
|
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 (
|
|
378
|
-
+ (
|
|
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,
|
|
385
|
-
+
|
|
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
|
|
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="' +
|
|
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),
|
|
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
|