signalk-race-control 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/public/app.js +145 -1
- package/public/index.html +25 -0
- package/public/style.css +44 -0
package/README.md
CHANGED
|
@@ -73,6 +73,14 @@ and a live projected finishing order — during and after the race.
|
|
|
73
73
|
- **Editable finish times** — click **Now** to record a finish as it happens, type a
|
|
74
74
|
specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
|
|
75
75
|
**Clear** to undo. Handles races that cross midnight.
|
|
76
|
+
- **Start timer** — once a start line is set and a start is scheduled, an expandable
|
|
77
|
+
**Start timer** panel shows a countdown to the gun alongside three live numbers for
|
|
78
|
+
this vessel specifically (from its own SignalK GPS/speed, not any other tracked
|
|
79
|
+
boat): distance to the line, ETA to reach it at current speed, and **time to burn** —
|
|
80
|
+
the countdown minus that ETA. Positive means time to spare (you'll arrive early, so
|
|
81
|
+
slow down or take a longer approach); negative means you're behind schedule to make
|
|
82
|
+
the line before the gun. Disappears once the race actually starts, since the
|
|
83
|
+
pre-start approach is moot by then.
|
|
76
84
|
- **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
|
|
77
85
|
marks, and the finish line (or click **Use my position** if you're sitting at that
|
|
78
86
|
spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Joachim Bakke <github@heiamoss.com>",
|
package/public/app.js
CHANGED
|
@@ -85,6 +85,14 @@
|
|
|
85
85
|
const replaySpeedSlider = document.getElementById('replaySpeedSlider');
|
|
86
86
|
const replaySpeedLabel = document.getElementById('replaySpeedLabel');
|
|
87
87
|
const replayLiveBtn = document.getElementById('replayLiveBtn');
|
|
88
|
+
const startTimerSection = document.getElementById('startTimerSection');
|
|
89
|
+
const startTimerToggleBtn = document.getElementById('startTimerToggleBtn');
|
|
90
|
+
const startTimerBody = document.getElementById('startTimerBody');
|
|
91
|
+
const startCountdownValue = document.getElementById('startCountdownValue');
|
|
92
|
+
const startDtlValue = document.getElementById('startDtlValue');
|
|
93
|
+
const startEtaValue = document.getElementById('startEtaValue');
|
|
94
|
+
const startBurnValue = document.getElementById('startBurnValue');
|
|
95
|
+
const startTimerNote = document.getElementById('startTimerNote');
|
|
88
96
|
const raceImportSection = document.getElementById('raceImportSection');
|
|
89
97
|
const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
|
|
90
98
|
const raceImportBody = document.getElementById('raceImportBody');
|
|
@@ -877,6 +885,123 @@
|
|
|
877
885
|
return null;
|
|
878
886
|
}
|
|
879
887
|
|
|
888
|
+
// ---- Start timer ------------------------------------------------------
|
|
889
|
+
// Countdown to the scheduled start + a live "distance/ETA to the start
|
|
890
|
+
// line, time to burn" instrument for this vessel — the same thing a
|
|
891
|
+
// start-line transit timer app shows, computed from this vessel's own
|
|
892
|
+
// live position/speed (not any other boat's).
|
|
893
|
+
|
|
894
|
+
let selfNav = null; // {lat, lon, sogMs} | null, refreshed on its own poll — see below
|
|
895
|
+
|
|
896
|
+
async function fetchSelfNav() {
|
|
897
|
+
try {
|
|
898
|
+
const [posLeaf, sogLeaf] = await Promise.all([
|
|
899
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
|
|
900
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
|
|
901
|
+
]);
|
|
902
|
+
const v = posLeaf && posLeaf.value;
|
|
903
|
+
if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') return null;
|
|
904
|
+
const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
|
|
905
|
+
return { lat: v.latitude, lon: v.longitude, sogMs };
|
|
906
|
+
} catch (e) {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const MS_TO_KNOTS = 1.9438444924574;
|
|
912
|
+
function toRad(deg) {
|
|
913
|
+
return (deg * Math.PI) / 180;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Perpendicular distance from a point to the start-line segment (or to
|
|
917
|
+
// its nearest end, if the point doesn't fall between the two ends) — a
|
|
918
|
+
// flat-earth approximation local to the line, which is fine at the scale
|
|
919
|
+
// of a start line and a boat's approach to it (same simplifying
|
|
920
|
+
// assumption the course chart's own projection already makes).
|
|
921
|
+
function distanceToSegmentNm(p, a, b) {
|
|
922
|
+
const meanLat = (a.lat + b.lat + p.lat) / 3;
|
|
923
|
+
const cosLat = Math.cos(toRad(meanLat)) || 1;
|
|
924
|
+
const NM_PER_DEG_LAT = 60;
|
|
925
|
+
const toXY = (pt) => ({ x: pt.lon * cosLat * NM_PER_DEG_LAT, y: pt.lat * NM_PER_DEG_LAT });
|
|
926
|
+
const P = toXY(p);
|
|
927
|
+
const A = toXY(a);
|
|
928
|
+
const B = toXY(b);
|
|
929
|
+
const abx = B.x - A.x;
|
|
930
|
+
const aby = B.y - A.y;
|
|
931
|
+
const lenSq = abx * abx + aby * aby;
|
|
932
|
+
let t = lenSq > 0 ? ((P.x - A.x) * abx + (P.y - A.y) * aby) / lenSq : 0;
|
|
933
|
+
t = Math.max(0, Math.min(1, t));
|
|
934
|
+
const cx = A.x + abx * t;
|
|
935
|
+
const cy = A.y + aby * t;
|
|
936
|
+
const dx = P.x - cx;
|
|
937
|
+
const dy = P.y - cy;
|
|
938
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function fmtSignedDuration(ms) {
|
|
942
|
+
const sign = ms < 0 ? '-' : '+';
|
|
943
|
+
const s = Math.round(Math.abs(ms) / 1000);
|
|
944
|
+
const h = Math.floor(s / 3600);
|
|
945
|
+
const m = Math.floor((s % 3600) / 60);
|
|
946
|
+
const sec = s % 60;
|
|
947
|
+
return sign + (h ? `${h}:${String(m).padStart(2, '0')}` : m) + ':' + String(sec).padStart(2, '0');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Shown whenever there's a start line to approach and the race hasn't
|
|
951
|
+
// actually started yet (pre-start is moot once it has) — refreshes every
|
|
952
|
+
// tick from the last-polled selfNav, independent of whether that poll
|
|
953
|
+
// itself just ran.
|
|
954
|
+
function renderStartTimer() {
|
|
955
|
+
const hasStartLine = !!(raceState && raceState.course && raceState.course.startLine);
|
|
956
|
+
startTimerSection.hidden = !raceState || !hasStartLine || !!raceState.startTime;
|
|
957
|
+
if (startTimerSection.hidden || startTimerBody.hidden) return;
|
|
958
|
+
|
|
959
|
+
const target = raceState.scheduledStart;
|
|
960
|
+
if (!target) {
|
|
961
|
+
startCountdownValue.textContent = '--:--:--';
|
|
962
|
+
startDtlValue.textContent = '--';
|
|
963
|
+
startEtaValue.textContent = '--:--:--';
|
|
964
|
+
startBurnValue.textContent = '--';
|
|
965
|
+
startBurnValue.className = 'start-timer-value';
|
|
966
|
+
startTimerNote.textContent = 'Set a scheduled start time above to see the countdown and burn time here.';
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
const toStartMs = target - now;
|
|
971
|
+
startCountdownValue.textContent = fmtDuration(Math.max(0, toStartMs));
|
|
972
|
+
|
|
973
|
+
if (!selfNav) {
|
|
974
|
+
startDtlValue.textContent = '--';
|
|
975
|
+
startEtaValue.textContent = '--:--:--';
|
|
976
|
+
startBurnValue.textContent = '--';
|
|
977
|
+
startBurnValue.className = 'start-timer-value';
|
|
978
|
+
startTimerNote.textContent = "Waiting for this vessel's own position from SignalK.";
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const [a, b] = raceState.course.startLine;
|
|
982
|
+
const dtlNm = distanceToSegmentNm(selfNav, a, b);
|
|
983
|
+
startDtlValue.textContent = `${dtlNm.toFixed(2)}nm`;
|
|
984
|
+
|
|
985
|
+
if (selfNav.sogMs == null || selfNav.sogMs < 0.25) {
|
|
986
|
+
startEtaValue.textContent = '--:--:--';
|
|
987
|
+
startBurnValue.textContent = '--';
|
|
988
|
+
startBurnValue.className = 'start-timer-value';
|
|
989
|
+
startTimerNote.textContent = 'Waiting for this vessel to be making way (SOG) to estimate ETA and burn time.';
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const sogKn = selfNav.sogMs * MS_TO_KNOTS;
|
|
993
|
+
const etaMs = (dtlNm / sogKn) * 3600 * 1000;
|
|
994
|
+
startEtaValue.textContent = new Date(now + etaMs).toLocaleTimeString();
|
|
995
|
+
|
|
996
|
+
const burnMs = toStartMs - etaMs;
|
|
997
|
+
startBurnValue.textContent = fmtSignedDuration(burnMs);
|
|
998
|
+
startBurnValue.className = 'start-timer-value ' + (burnMs >= 0 ? 'early' : 'late');
|
|
999
|
+
startTimerNote.textContent =
|
|
1000
|
+
burnMs >= 0
|
|
1001
|
+
? "Positive: time to spare before the gun at this speed — you'll arrive at the line before it, so you have time to burn."
|
|
1002
|
+
: "Negative: you're behind schedule to reach the line at this speed before the gun.";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
880
1005
|
// Generic substring-match autocomplete, wiring `input` to `dropdown` (a
|
|
881
1006
|
// hidden .suggestions element already sitting next to it in the DOM) —
|
|
882
1007
|
// same matching/keyboard-nav behavior as the boat-name autocomplete
|
|
@@ -1854,6 +1979,7 @@
|
|
|
1854
1979
|
exportOfflineBtn.hidden = !raceState;
|
|
1855
1980
|
courseSection.hidden = !raceState;
|
|
1856
1981
|
raceImportSection.hidden = !raceState || !raceImportEnabled;
|
|
1982
|
+
renderStartTimer();
|
|
1857
1983
|
|
|
1858
1984
|
if (!raceState) {
|
|
1859
1985
|
emptyMsg.hidden = true;
|
|
@@ -2123,6 +2249,11 @@
|
|
|
2123
2249
|
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
2124
2250
|
if (!courseBody.hidden) renderChart();
|
|
2125
2251
|
});
|
|
2252
|
+
startTimerToggleBtn.addEventListener('click', () => {
|
|
2253
|
+
startTimerBody.hidden = !startTimerBody.hidden;
|
|
2254
|
+
startTimerToggleBtn.textContent = (startTimerBody.hidden ? '▸' : '▾') + ' Start timer';
|
|
2255
|
+
if (!startTimerBody.hidden) renderStartTimer();
|
|
2256
|
+
});
|
|
2126
2257
|
raceImportToggleBtn.addEventListener('click', () => {
|
|
2127
2258
|
raceImportBody.hidden = !raceImportBody.hidden;
|
|
2128
2259
|
raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
|
|
@@ -2199,14 +2330,27 @@
|
|
|
2199
2330
|
renderChart();
|
|
2200
2331
|
});
|
|
2201
2332
|
|
|
2333
|
+
async function refreshSelfNav() {
|
|
2334
|
+
selfNav = await fetchSelfNav();
|
|
2335
|
+
renderStartTimer();
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2202
2338
|
async function init() {
|
|
2203
2339
|
await loadVetEnabled();
|
|
2204
2340
|
await loadRaceImportEnabled();
|
|
2205
|
-
await Promise.all([
|
|
2341
|
+
await Promise.all([
|
|
2342
|
+
loadVessels(),
|
|
2343
|
+
loadBoatRegistry(),
|
|
2344
|
+
loadHandicapRegister(false),
|
|
2345
|
+
loadWaypoints(),
|
|
2346
|
+
loadRacesList(),
|
|
2347
|
+
refreshSelfNav()
|
|
2348
|
+
]);
|
|
2206
2349
|
await loadRaceState();
|
|
2207
2350
|
render();
|
|
2208
2351
|
setInterval(render, 1000);
|
|
2209
2352
|
setInterval(loadVessels, 10000);
|
|
2353
|
+
setInterval(refreshSelfNav, 3000);
|
|
2210
2354
|
setInterval(() => {
|
|
2211
2355
|
if (activeRaceId) loadRaceState();
|
|
2212
2356
|
}, 5000);
|
package/public/index.html
CHANGED
|
@@ -95,6 +95,31 @@
|
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
|
+
<div id="startTimerSection" class="course-section" hidden>
|
|
99
|
+
<button id="startTimerToggleBtn" type="button" class="link-btn">▸ Start timer</button>
|
|
100
|
+
<div id="startTimerBody" hidden>
|
|
101
|
+
<div class="start-timer-grid">
|
|
102
|
+
<div class="start-timer-tile">
|
|
103
|
+
<div class="start-timer-label">Countdown to start</div>
|
|
104
|
+
<div id="startCountdownValue" class="start-timer-value">--:--:--</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="start-timer-tile">
|
|
107
|
+
<div class="start-timer-label">Distance to line</div>
|
|
108
|
+
<div id="startDtlValue" class="start-timer-value">--</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="start-timer-tile">
|
|
111
|
+
<div class="start-timer-label">ETA to line</div>
|
|
112
|
+
<div id="startEtaValue" class="start-timer-value">--:--:--</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="start-timer-tile">
|
|
115
|
+
<div class="start-timer-label">Time to burn</div>
|
|
116
|
+
<div id="startBurnValue" class="start-timer-value">--</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<p id="startTimerNote" class="start-timer-note"></p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
98
123
|
<div id="raceImportSection" class="course-section" hidden>
|
|
99
124
|
<button id="raceImportToggleBtn" type="button" class="link-btn">▸ Import boats from Manage2Sail</button>
|
|
100
125
|
<div id="raceImportBody" hidden>
|
package/public/style.css
CHANGED
|
@@ -226,6 +226,50 @@ main {
|
|
|
226
226
|
padding-bottom: 1rem;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
.start-timer-grid {
|
|
230
|
+
margin-top: 0.75rem;
|
|
231
|
+
display: grid;
|
|
232
|
+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
233
|
+
gap: 1rem;
|
|
234
|
+
max-width: 44rem;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.start-timer-tile {
|
|
238
|
+
background: var(--panel);
|
|
239
|
+
border: 1px solid var(--border);
|
|
240
|
+
border-radius: 8px;
|
|
241
|
+
padding: 0.75rem 1rem;
|
|
242
|
+
text-align: center;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.start-timer-label {
|
|
246
|
+
font-size: 0.7rem;
|
|
247
|
+
text-transform: uppercase;
|
|
248
|
+
letter-spacing: 0.04em;
|
|
249
|
+
color: var(--muted);
|
|
250
|
+
margin-bottom: 0.3rem;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.start-timer-value {
|
|
254
|
+
font-size: 1.4rem;
|
|
255
|
+
font-weight: 700;
|
|
256
|
+
font-variant-numeric: tabular-nums;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.start-timer-value.early {
|
|
260
|
+
color: var(--good);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.start-timer-value.late {
|
|
264
|
+
color: var(--bad);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.start-timer-note {
|
|
268
|
+
margin-top: 0.6rem;
|
|
269
|
+
font-size: 0.8rem;
|
|
270
|
+
color: var(--muted);
|
|
271
|
+
}
|
|
272
|
+
|
|
229
273
|
.course-grid {
|
|
230
274
|
margin-top: 0.75rem;
|
|
231
275
|
display: grid;
|