signalk-race-control 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/index.js +13 -2
- package/package.json +1 -1
- package/public/app.js +180 -14
- package/public/index.html +26 -1
- package/public/style.css +59 -1
package/README.md
CHANGED
|
@@ -25,7 +25,13 @@ and a live projected finishing order — during and after the race.
|
|
|
25
25
|
- **Add/remove boats explicitly** — type a name (autocompletes against the VET
|
|
26
26
|
register, the cross-race boat registry, and any live AIS/self vessel — matching
|
|
27
27
|
anywhere in the name, not just the start) and click **Add Boat**. Boats aren't
|
|
28
|
-
auto-populated from AIS; you control exactly who's racing.
|
|
28
|
+
auto-populated from AIS; you control exactly who's racing. Rows stay in the order
|
|
29
|
+
boats were added — adding or removing one doesn't reshuffle the rest — until the
|
|
30
|
+
race actually starts, when the table switches to rank order; whichever boat is
|
|
31
|
+
marked **self** always stays on top regardless of phase. The **Boat** column stays
|
|
32
|
+
pinned in place while scrolling sideways through the rest of the (wide) table, and
|
|
33
|
+
the page itself isn't width-capped, so a larger screen shows more columns at once
|
|
34
|
+
without scrolling.
|
|
29
35
|
- **Elapsed / corrected time** — Time-on-Time correction: `corrected = elapsed × TCF`,
|
|
30
36
|
where TCF is edited per boat directly in the webapp and persisted server-side,
|
|
31
37
|
shared by everyone viewing the page.
|
|
@@ -73,6 +79,14 @@ and a live projected finishing order — during and after the race.
|
|
|
73
79
|
- **Editable finish times** — click **Now** to record a finish as it happens, type a
|
|
74
80
|
specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
|
|
75
81
|
**Clear** to undo. Handles races that cross midnight.
|
|
82
|
+
- **Start timer** — once a start line is set and a start is scheduled, an expandable
|
|
83
|
+
**Start timer** panel shows a countdown to the gun alongside three live numbers for
|
|
84
|
+
this vessel specifically (from its own SignalK GPS/speed, not any other tracked
|
|
85
|
+
boat): distance to the line, ETA to reach it at current speed, and **time to burn** —
|
|
86
|
+
the countdown minus that ETA. Positive means time to spare (you'll arrive early, so
|
|
87
|
+
slow down or take a longer approach); negative means you're behind schedule to make
|
|
88
|
+
the line before the gun. Disappears once the race actually starts, since the
|
|
89
|
+
pre-start approach is moot by then.
|
|
76
90
|
- **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
|
|
77
91
|
marks, and the finish line (or click **Use my position** if you're sitting at that
|
|
78
92
|
spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
|
package/index.js
CHANGED
|
@@ -426,7 +426,7 @@ button.confirming { background: var(--bad); color: #2a0a0a; border-color: var(--
|
|
|
426
426
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
427
427
|
.status { min-height: 1.2em; margin-top: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
|
428
428
|
.status.error { color: var(--bad); }
|
|
429
|
-
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom));
|
|
429
|
+
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); margin: 0 auto; }
|
|
430
430
|
.add-boat-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
431
431
|
.add-boat-row input[type='text'] { flex: 1; min-width: 12rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
432
432
|
.add-boat-row input[type='number'] { width: 6rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
@@ -434,6 +434,8 @@ main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); max-width: 9
|
|
|
434
434
|
table { width: 100%; min-width: 44rem; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
|
435
435
|
thead th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
436
436
|
tbody td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
437
|
+
.boat-name-col { position: sticky; left: 0; z-index: 1; background: var(--bg); }
|
|
438
|
+
thead .boat-name-col { z-index: 2; }
|
|
437
439
|
tbody tr.finished td { color: var(--good); }
|
|
438
440
|
tbody tr.dnf td { color: var(--muted); }
|
|
439
441
|
.dnf-tag { color: var(--bad); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.03em; }
|
|
@@ -498,7 +500,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
498
500
|
<table id="boatsTable">
|
|
499
501
|
<thead>
|
|
500
502
|
<tr>
|
|
501
|
-
<th>Boat</th>
|
|
503
|
+
<th class="boat-name-col">Boat</th>
|
|
502
504
|
<th>Sail #</th>
|
|
503
505
|
<th>TCF</th>
|
|
504
506
|
<th id="vetAlternativesTh" hidden>VET alternatives</th>
|
|
@@ -817,6 +819,14 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
817
819
|
return { boat: boat, elapsedMs: elapsedMs, correctedMs: correctedMs, rankMs: rankMs };
|
|
818
820
|
})
|
|
819
821
|
.sort(function (a, b) {
|
|
822
|
+
// Self always leads, whatever else is true.
|
|
823
|
+
var aSelf = a.boat.id === race.selfBoatId;
|
|
824
|
+
var bSelf = b.boat.id === race.selfBoatId;
|
|
825
|
+
if (aSelf !== bSelf) return aSelf ? -1 : 1;
|
|
826
|
+
// Before the race actually starts, nobody has a corrected time to
|
|
827
|
+
// rank by anyway — leave order exactly as race.boats gave it
|
|
828
|
+
// (registration order) rather than reshuffling on every add/remove.
|
|
829
|
+
if (!race.startTime) return 0;
|
|
820
830
|
if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
|
|
821
831
|
if (a.rankMs == null) return 1;
|
|
822
832
|
if (b.rankMs == null) return -1;
|
|
@@ -938,6 +948,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
938
948
|
});
|
|
939
949
|
var nameSpan = document.createElement('span');
|
|
940
950
|
var tdName = document.createElement('td');
|
|
951
|
+
tdName.className = 'boat-name-col';
|
|
941
952
|
tdName.append(selfBtn, nameSpan);
|
|
942
953
|
|
|
943
954
|
var sailNumberInput = document.createElement('input');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
|
@@ -1482,6 +1607,19 @@
|
|
|
1482
1607
|
};
|
|
1483
1608
|
})
|
|
1484
1609
|
.sort((a, b) => {
|
|
1610
|
+
// Self always leads, whatever else is true — it's the boat whoever
|
|
1611
|
+
// is looking at this screen cares about finding without hunting
|
|
1612
|
+
// for it.
|
|
1613
|
+
const aSelf = a.boatId === raceState.selfBoatId;
|
|
1614
|
+
const bSelf = b.boatId === raceState.selfBoatId;
|
|
1615
|
+
if (aSelf !== bSelf) return aSelf ? -1 : 1;
|
|
1616
|
+
// Before the race actually starts, nobody has a corrected time to
|
|
1617
|
+
// rank by anyway — sorting by name (or anything else derived) just
|
|
1618
|
+
// reshuffled the rows confusingly every time a boat was added or
|
|
1619
|
+
// removed. Rely on sort's stability and leave order exactly as
|
|
1620
|
+
// Object.values(raceState.boats) gave it (i.e. registration order)
|
|
1621
|
+
// until there's an actual race to rank.
|
|
1622
|
+
if (!raceState.startTime) return 0;
|
|
1485
1623
|
if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
|
|
1486
1624
|
if (a.rankMs == null) return 1;
|
|
1487
1625
|
if (b.rankMs == null) return -1;
|
|
@@ -1567,6 +1705,7 @@
|
|
|
1567
1705
|
nameSpan.className = 'boat-name-text';
|
|
1568
1706
|
|
|
1569
1707
|
const tdName = document.createElement('td');
|
|
1708
|
+
tdName.className = 'boat-name-col';
|
|
1570
1709
|
tdName.append(selfBtn, nameSpan);
|
|
1571
1710
|
|
|
1572
1711
|
const sailNumberInput = document.createElement('input');
|
|
@@ -1854,6 +1993,7 @@
|
|
|
1854
1993
|
exportOfflineBtn.hidden = !raceState;
|
|
1855
1994
|
courseSection.hidden = !raceState;
|
|
1856
1995
|
raceImportSection.hidden = !raceState || !raceImportEnabled;
|
|
1996
|
+
renderStartTimer();
|
|
1857
1997
|
|
|
1858
1998
|
if (!raceState) {
|
|
1859
1999
|
emptyMsg.hidden = true;
|
|
@@ -1915,20 +2055,28 @@
|
|
|
1915
2055
|
const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
|
|
1916
2056
|
|
|
1917
2057
|
boats.forEach((b) => {
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
row = buildRow(b.boatId);
|
|
1921
|
-
rows.set(b.boatId, row);
|
|
1922
|
-
}
|
|
1923
|
-
// Moving an already-attached node to its sorted position keeps it
|
|
1924
|
-
// intact (focus, in-progress edits) rather than recreating it — but
|
|
1925
|
-
// the move itself still resets a text cursor and closes an open
|
|
1926
|
-
// <select>, so skip it entirely while the user has something in this
|
|
1927
|
-
// row focused. It'll snap to its correct position as soon as they're
|
|
1928
|
-
// done (next render after focus moves away).
|
|
1929
|
-
if (!row.tr.contains(document.activeElement)) {
|
|
1930
|
-
boatsBody.appendChild(row.tr);
|
|
2058
|
+
if (!rows.get(b.boatId)) {
|
|
2059
|
+
rows.set(b.boatId, buildRow(b.boatId));
|
|
1931
2060
|
}
|
|
2061
|
+
});
|
|
2062
|
+
// Moving already-attached nodes to their sorted position keeps them
|
|
2063
|
+
// intact (focus, in-progress edits) rather than recreating them — but
|
|
2064
|
+
// the move itself still resets a text cursor and closes an open
|
|
2065
|
+
// <select>, so the whole reorder pass is skipped while the user has
|
|
2066
|
+
// anything in ANY row focused, not just the row that would move.
|
|
2067
|
+
// Reordering around a skipped row by moving everyone else in sequence
|
|
2068
|
+
// can still shuffle it to a different spot as a side effect of those
|
|
2069
|
+
// other moves, which is exactly the "keep self on top" guarantee this
|
|
2070
|
+
// was supposed to preserve — so it's all-or-nothing instead. It'll
|
|
2071
|
+
// snap to the fully correct order on the next render once focus moves
|
|
2072
|
+
// away.
|
|
2073
|
+
const anyRowFocused = boats.some((b) => rows.get(b.boatId).tr.contains(document.activeElement));
|
|
2074
|
+
if (!anyRowFocused) {
|
|
2075
|
+
boats.forEach((b) => boatsBody.appendChild(rows.get(b.boatId).tr));
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
boats.forEach((b) => {
|
|
2079
|
+
const row = rows.get(b.boatId);
|
|
1932
2080
|
|
|
1933
2081
|
row.tr.classList.toggle('finished', !!b.finishTime);
|
|
1934
2082
|
row.tr.classList.toggle('dnf', !!b.dnf);
|
|
@@ -2123,6 +2271,11 @@
|
|
|
2123
2271
|
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
2124
2272
|
if (!courseBody.hidden) renderChart();
|
|
2125
2273
|
});
|
|
2274
|
+
startTimerToggleBtn.addEventListener('click', () => {
|
|
2275
|
+
startTimerBody.hidden = !startTimerBody.hidden;
|
|
2276
|
+
startTimerToggleBtn.textContent = (startTimerBody.hidden ? '▸' : '▾') + ' Start timer';
|
|
2277
|
+
if (!startTimerBody.hidden) renderStartTimer();
|
|
2278
|
+
});
|
|
2126
2279
|
raceImportToggleBtn.addEventListener('click', () => {
|
|
2127
2280
|
raceImportBody.hidden = !raceImportBody.hidden;
|
|
2128
2281
|
raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
|
|
@@ -2199,14 +2352,27 @@
|
|
|
2199
2352
|
renderChart();
|
|
2200
2353
|
});
|
|
2201
2354
|
|
|
2355
|
+
async function refreshSelfNav() {
|
|
2356
|
+
selfNav = await fetchSelfNav();
|
|
2357
|
+
renderStartTimer();
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2202
2360
|
async function init() {
|
|
2203
2361
|
await loadVetEnabled();
|
|
2204
2362
|
await loadRaceImportEnabled();
|
|
2205
|
-
await Promise.all([
|
|
2363
|
+
await Promise.all([
|
|
2364
|
+
loadVessels(),
|
|
2365
|
+
loadBoatRegistry(),
|
|
2366
|
+
loadHandicapRegister(false),
|
|
2367
|
+
loadWaypoints(),
|
|
2368
|
+
loadRacesList(),
|
|
2369
|
+
refreshSelfNav()
|
|
2370
|
+
]);
|
|
2206
2371
|
await loadRaceState();
|
|
2207
2372
|
render();
|
|
2208
2373
|
setInterval(render, 1000);
|
|
2209
2374
|
setInterval(loadVessels, 10000);
|
|
2375
|
+
setInterval(refreshSelfNav, 3000);
|
|
2210
2376
|
setInterval(() => {
|
|
2211
2377
|
if (activeRaceId) loadRaceState();
|
|
2212
2378
|
}, 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>
|
|
@@ -122,7 +147,7 @@
|
|
|
122
147
|
<table id="boatsTable">
|
|
123
148
|
<thead>
|
|
124
149
|
<tr>
|
|
125
|
-
<th>Boat</th>
|
|
150
|
+
<th class="boat-name-col">Boat</th>
|
|
126
151
|
<th>Sail #</th>
|
|
127
152
|
<th>MMSI</th>
|
|
128
153
|
<th>TCF</th>
|
package/public/style.css
CHANGED
|
@@ -216,7 +216,6 @@ button:disabled {
|
|
|
216
216
|
|
|
217
217
|
main {
|
|
218
218
|
padding: 1rem 1.5rem 2rem;
|
|
219
|
-
max-width: 1000px;
|
|
220
219
|
margin: 0 auto;
|
|
221
220
|
}
|
|
222
221
|
|
|
@@ -226,6 +225,50 @@ main {
|
|
|
226
225
|
padding-bottom: 1rem;
|
|
227
226
|
}
|
|
228
227
|
|
|
228
|
+
.start-timer-grid {
|
|
229
|
+
margin-top: 0.75rem;
|
|
230
|
+
display: grid;
|
|
231
|
+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
232
|
+
gap: 1rem;
|
|
233
|
+
max-width: 44rem;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.start-timer-tile {
|
|
237
|
+
background: var(--panel);
|
|
238
|
+
border: 1px solid var(--border);
|
|
239
|
+
border-radius: 8px;
|
|
240
|
+
padding: 0.75rem 1rem;
|
|
241
|
+
text-align: center;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.start-timer-label {
|
|
245
|
+
font-size: 0.7rem;
|
|
246
|
+
text-transform: uppercase;
|
|
247
|
+
letter-spacing: 0.04em;
|
|
248
|
+
color: var(--muted);
|
|
249
|
+
margin-bottom: 0.3rem;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.start-timer-value {
|
|
253
|
+
font-size: 1.4rem;
|
|
254
|
+
font-weight: 700;
|
|
255
|
+
font-variant-numeric: tabular-nums;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.start-timer-value.early {
|
|
259
|
+
color: var(--good);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.start-timer-value.late {
|
|
263
|
+
color: var(--bad);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.start-timer-note {
|
|
267
|
+
margin-top: 0.6rem;
|
|
268
|
+
font-size: 0.8rem;
|
|
269
|
+
color: var(--muted);
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
.course-grid {
|
|
230
273
|
margin-top: 0.75rem;
|
|
231
274
|
display: grid;
|
|
@@ -547,6 +590,21 @@ tbody td {
|
|
|
547
590
|
border-bottom: 1px solid var(--border);
|
|
548
591
|
}
|
|
549
592
|
|
|
593
|
+
/* Frozen first column, so the boat's name stays visible while scrolling
|
|
594
|
+
the (much wider than most screens, especially phones in landscape)
|
|
595
|
+
table sideways to reach the other columns. Needs its own opaque
|
|
596
|
+
background so scrolled-under cells don't show through. */
|
|
597
|
+
.boat-name-col {
|
|
598
|
+
position: sticky;
|
|
599
|
+
left: 0;
|
|
600
|
+
z-index: 1;
|
|
601
|
+
background: var(--bg);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
thead .boat-name-col {
|
|
605
|
+
z-index: 2;
|
|
606
|
+
}
|
|
607
|
+
|
|
550
608
|
tbody tr.finished td {
|
|
551
609
|
color: var(--good);
|
|
552
610
|
}
|