signalk-race-control 0.11.1 → 0.12.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 +21 -1
- package/index.js +65 -1
- package/package.json +1 -1
- package/public/app.js +151 -4
- package/public/index.html +14 -0
- package/public/style.css +12 -0
package/README.md
CHANGED
|
@@ -170,7 +170,27 @@ and a live projected finishing order — during and after the race.
|
|
|
170
170
|
waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
|
|
171
171
|
standard resources API — both directions (reading existing waypoints for the
|
|
172
172
|
autocomplete, and publishing the saved course) only do anything if your server has a
|
|
173
|
-
resources provider installed; they're a no-op otherwise, never a failure.
|
|
173
|
+
resources provider installed; they're a no-op otherwise, never a failure. Once a race
|
|
174
|
+
has started, the moment this vessel's own position actually crosses the start line
|
|
175
|
+
(a real crossing between two GPS fixes, not just getting close to it), the course is
|
|
176
|
+
also activated as this vessel's SignalK navigation route — so a chart plotter reading
|
|
177
|
+
the standard Course API starts showing/guiding to the next mark automatically, without
|
|
178
|
+
anyone having to find and select the route by hand right as the race gets underway.
|
|
179
|
+
Requires both a resources provider and a server new enough to have the Course API;
|
|
180
|
+
a no-op otherwise. Whenever this vessel's own true wind direction and a moving SOG
|
|
181
|
+
are available from SignalK, the chart also draws a pair of 10-minute laylines from its
|
|
182
|
+
live position — where a further 10 minutes on each close-hauled tack would put it,
|
|
183
|
+
using a generic ~40° tack angle rather than this boat's own polar data. Absent
|
|
184
|
+
whenever that wind/speed data isn't, same as everything else on this chart that
|
|
185
|
+
depends on live SignalK data. A **Wind shift** instrument sits above the chart
|
|
186
|
+
alongside it, once there's enough live wind history (30s or more) to call a trend
|
|
187
|
+
from: it tracks a rolling ~3-minute average true wind direction as a baseline and
|
|
188
|
+
compares the current reading against it, reporting **Lift** or **Header** (with the
|
|
189
|
+
shift in degrees) or **Steady** below a small deadband — tack-aware, since the same
|
|
190
|
+
shift is a lift on one tack and a header on the other, using this vessel's own
|
|
191
|
+
heading to tell which. It's a wind-trend read, not a mark-aware tactical simulator —
|
|
192
|
+
no probability-of-gain number, just whether the breeze has moved for or against
|
|
193
|
+
whichever tack you're currently on.
|
|
174
194
|
- **Estimated finish time & live rank** — while a boat is still racing, if it has a
|
|
175
195
|
live AIS position and speed and the race has a finish line, the plugin projects a
|
|
176
196
|
finish time and corrected time from its remaining distance and speed, and ranks it
|
package/index.js
CHANGED
|
@@ -305,6 +305,7 @@ function ensureRaceShape(race) {
|
|
|
305
305
|
if (race.stopTime === undefined) race.stopTime = null;
|
|
306
306
|
if (race.scheduledCallOff === undefined) race.scheduledCallOff = null;
|
|
307
307
|
if (race.multiDay === undefined) race.multiDay = false;
|
|
308
|
+
if (race.courseActivated === undefined) race.courseActivated = false;
|
|
308
309
|
if (!race.classes) race.classes = [];
|
|
309
310
|
race.classes.forEach((c) => {
|
|
310
311
|
if (c.startTime === undefined) c.startTime = null;
|
|
@@ -364,6 +365,22 @@ function midpoint(a, b) {
|
|
|
364
365
|
return { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
|
|
365
366
|
}
|
|
366
367
|
|
|
368
|
+
// True if segment p1-p2 crosses segment p3-p4 — the standard orientation
|
|
369
|
+
// (cross-product sign) test. Treats lat/lon as a flat plane, fine at the
|
|
370
|
+
// scale of a start line (tens to low hundreds of meters). Used to detect a
|
|
371
|
+
// boat crossing the start line between two consecutive position samples;
|
|
372
|
+
// doesn't distinguish direction (inbound vs outbound) or handle the
|
|
373
|
+
// collinear/touching edge case — a crossing landing exactly on that is
|
|
374
|
+
// astronomically unlikely, and would just be caught on the next sample.
|
|
375
|
+
function segmentsIntersect(p1, p2, p3, p4) {
|
|
376
|
+
const cross = (o, a, b) => (a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon);
|
|
377
|
+
const d1 = cross(p3, p4, p1);
|
|
378
|
+
const d2 = cross(p3, p4, p2);
|
|
379
|
+
const d3 = cross(p1, p2, p3);
|
|
380
|
+
const d4 = cross(p1, p2, p4);
|
|
381
|
+
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
|
382
|
+
}
|
|
383
|
+
|
|
367
384
|
function validateCoordPoint(p) {
|
|
368
385
|
if (!p || typeof p.lat !== 'number' || typeof p.lon !== 'number' || !isFinite(p.lat) || !isFinite(p.lon)) {
|
|
369
386
|
return null;
|
|
@@ -1600,6 +1617,7 @@ module.exports = function (app) {
|
|
|
1600
1617
|
let dataFile = null;
|
|
1601
1618
|
const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
|
|
1602
1619
|
const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
|
|
1620
|
+
let lastSelfPos = null; // {lat, lon} as of the previous checkStartLineCrossings tick
|
|
1603
1621
|
|
|
1604
1622
|
// setTimeout's delay is a 32-bit signed int internally — anything past
|
|
1605
1623
|
// ~24.8 days silently wraps and fires almost immediately instead of
|
|
@@ -1988,6 +2006,48 @@ module.exports = function (app) {
|
|
|
1988
2006
|
if (changed) saveState();
|
|
1989
2007
|
}
|
|
1990
2008
|
|
|
2009
|
+
// Once a race has started and this vessel (this SignalK server's own
|
|
2010
|
+
// position, not any boat in the fleet) actually crosses its start line,
|
|
2011
|
+
// activate that race's course as the vessel's current SignalK navigation
|
|
2012
|
+
// route — so a chart plotter (freeboard-sk etc.) reading the standard
|
|
2013
|
+
// Course API immediately starts showing/guiding to the next mark,
|
|
2014
|
+
// without anyone having to find and select the route by hand right as
|
|
2015
|
+
// the race gets underway. Detected as a genuine line crossing between
|
|
2016
|
+
// two consecutive position samples (not just proximity), same 15s
|
|
2017
|
+
// cadence as recordTrackSample. Best-effort and silently skipped if this
|
|
2018
|
+
// server has no Course API (older signalk-server) or no resourcesApi —
|
|
2019
|
+
// core race-control features never depend on this succeeding.
|
|
2020
|
+
async function checkStartLineCrossings() {
|
|
2021
|
+
let selfPos = null;
|
|
2022
|
+
try {
|
|
2023
|
+
const leaf = app.getSelfPath('navigation.position');
|
|
2024
|
+
const v = leaf && leaf.value;
|
|
2025
|
+
if (v && typeof v.latitude === 'number' && typeof v.longitude === 'number') {
|
|
2026
|
+
selfPos = { lat: v.latitude, lon: v.longitude };
|
|
2027
|
+
}
|
|
2028
|
+
} catch (e) {
|
|
2029
|
+
// no self position available yet
|
|
2030
|
+
}
|
|
2031
|
+
const prevPos = lastSelfPos;
|
|
2032
|
+
lastSelfPos = selfPos;
|
|
2033
|
+
if (!selfPos || !prevPos || typeof app.activateRoute !== 'function') return;
|
|
2034
|
+
for (const race of Object.values(state.races)) {
|
|
2035
|
+
if (!race.startTime || race.stopTime || race.courseActivated) continue;
|
|
2036
|
+
const line = race.course && race.course.startLine;
|
|
2037
|
+
if (!line) continue;
|
|
2038
|
+
if (!segmentsIntersect(prevPos, selfPos, line[0], line[1])) continue;
|
|
2039
|
+
try {
|
|
2040
|
+
await publishCourseResources(race);
|
|
2041
|
+
await app.activateRoute({ href: `/resources/routes/${deterministicUuid(`race-${race.id}-course`)}`, pointIndex: 0 });
|
|
2042
|
+
race.courseActivated = true;
|
|
2043
|
+
saveState();
|
|
2044
|
+
app.debug(`race-control: activated course for race "${race.name}" after crossing the start line`);
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
app.debug('race-control: could not activate course in SignalK: ' + e.message);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1991
2051
|
function disarmSchedule(raceId) {
|
|
1992
2052
|
const t = scheduleTimers.get(raceId);
|
|
1993
2053
|
if (t) {
|
|
@@ -2002,6 +2062,7 @@ module.exports = function (app) {
|
|
|
2002
2062
|
race.startTime = atTime;
|
|
2003
2063
|
race.scheduledStart = null;
|
|
2004
2064
|
race.stopTime = null;
|
|
2065
|
+
race.courseActivated = false;
|
|
2005
2066
|
Object.values(race.boats).forEach((b) => {
|
|
2006
2067
|
b.finishTime = null;
|
|
2007
2068
|
b.startTime = null;
|
|
@@ -2151,7 +2212,10 @@ module.exports = function (app) {
|
|
|
2151
2212
|
armSchedule(race);
|
|
2152
2213
|
armCallOffSchedule(race);
|
|
2153
2214
|
});
|
|
2154
|
-
trackTimer = setInterval(
|
|
2215
|
+
trackTimer = setInterval(() => {
|
|
2216
|
+
recordTrackSample();
|
|
2217
|
+
checkStartLineCrossings().catch((e) => app.debug('race-control: checkStartLineCrossings failed: ' + e.message));
|
|
2218
|
+
}, 15000);
|
|
2155
2219
|
app.setPluginStatus('Race control ready');
|
|
2156
2220
|
};
|
|
2157
2221
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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
|
@@ -125,6 +125,10 @@
|
|
|
125
125
|
const startEtaValue = document.getElementById('startEtaValue');
|
|
126
126
|
const startBurnValue = document.getElementById('startBurnValue');
|
|
127
127
|
const startTimerNote = document.getElementById('startTimerNote');
|
|
128
|
+
const windShiftBar = document.getElementById('windShiftBar');
|
|
129
|
+
const windShiftValue = document.getElementById('windShiftValue');
|
|
130
|
+
const windShiftTack = document.getElementById('windShiftTack');
|
|
131
|
+
const windShiftTwd = document.getElementById('windShiftTwd');
|
|
128
132
|
const raceImportSection = document.getElementById('raceImportSection');
|
|
129
133
|
const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
|
|
130
134
|
const raceImportBody = document.getElementById('raceImportBody');
|
|
@@ -1179,18 +1183,43 @@
|
|
|
1179
1183
|
// start-line transit timer app shows, computed from this vessel's own
|
|
1180
1184
|
// live position/speed (not any other boat's).
|
|
1181
1185
|
|
|
1182
|
-
let selfNav = null; // {lat, lon, sogMs} | null, refreshed on its own poll — see below
|
|
1186
|
+
let selfNav = null; // {lat, lon, sogMs, windDirTrueRad, headingRad} | null, refreshed on its own poll — see below
|
|
1187
|
+
|
|
1188
|
+
function normalizeRad(a) {
|
|
1189
|
+
const twoPi = Math.PI * 2;
|
|
1190
|
+
return ((a % twoPi) + twoPi) % twoPi;
|
|
1191
|
+
}
|
|
1183
1192
|
|
|
1184
1193
|
async function fetchSelfNav() {
|
|
1185
1194
|
try {
|
|
1186
|
-
const [posLeaf, sogLeaf] = await Promise.all([
|
|
1195
|
+
const [posLeaf, sogLeaf, headingLeaf, cogLeaf, windDirLeaf, windAngleGroundLeaf, windAngleWaterLeaf] = await Promise.all([
|
|
1187
1196
|
fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
|
|
1188
|
-
fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
|
|
1197
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null),
|
|
1198
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/headingTrue`).catch(() => null),
|
|
1199
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/courseOverGroundTrue`).catch(() => null),
|
|
1200
|
+
fetchJSON(`${SK_API}/vessels/self/environment/wind/directionTrue`).catch(() => null),
|
|
1201
|
+
fetchJSON(`${SK_API}/vessels/self/environment/wind/angleTrueGround`).catch(() => null),
|
|
1202
|
+
fetchJSON(`${SK_API}/vessels/self/environment/wind/angleTrueWater`).catch(() => null)
|
|
1189
1203
|
]);
|
|
1190
1204
|
const v = posLeaf && posLeaf.value;
|
|
1191
1205
|
if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') return null;
|
|
1192
1206
|
const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
|
|
1193
|
-
|
|
1207
|
+
// True wind direction, absolute (radians, 0 = north) — prefer a
|
|
1208
|
+
// sensor/instrument that already computed it; otherwise derive it
|
|
1209
|
+
// from a true wind ANGLE (relative to the bow) plus heading (or, if
|
|
1210
|
+
// no compass, course over ground as a rough stand-in). null — not
|
|
1211
|
+
// just a missing layline — if none of these are available, per
|
|
1212
|
+
// SignalK's own "best-effort, never a failure" convention elsewhere
|
|
1213
|
+
// in this app.
|
|
1214
|
+
let windDirTrueRad = null;
|
|
1215
|
+
const heading = headingLeaf && typeof headingLeaf.value === 'number' ? headingLeaf.value : cogLeaf && typeof cogLeaf.value === 'number' ? cogLeaf.value : null;
|
|
1216
|
+
if (windDirLeaf && typeof windDirLeaf.value === 'number') {
|
|
1217
|
+
windDirTrueRad = normalizeRad(windDirLeaf.value);
|
|
1218
|
+
} else if (heading != null) {
|
|
1219
|
+
const angle = windAngleGroundLeaf && typeof windAngleGroundLeaf.value === 'number' ? windAngleGroundLeaf.value : windAngleWaterLeaf && typeof windAngleWaterLeaf.value === 'number' ? windAngleWaterLeaf.value : null;
|
|
1220
|
+
if (angle != null) windDirTrueRad = normalizeRad(heading + angle);
|
|
1221
|
+
}
|
|
1222
|
+
return { lat: v.latitude, lon: v.longitude, sogMs, windDirTrueRad, headingRad: heading };
|
|
1194
1223
|
} catch (e) {
|
|
1195
1224
|
return null;
|
|
1196
1225
|
}
|
|
@@ -1200,6 +1229,95 @@
|
|
|
1200
1229
|
function toRad(deg) {
|
|
1201
1230
|
return (deg * Math.PI) / 180;
|
|
1202
1231
|
}
|
|
1232
|
+
function toDeg(rad) {
|
|
1233
|
+
return (rad * 180) / Math.PI;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const EARTH_RADIUS_M = 6371000;
|
|
1237
|
+
// Destination point a given bearing/distance from a start point (great-
|
|
1238
|
+
// circle "direct" geodesic problem) — used to draw laylines a fixed
|
|
1239
|
+
// sailing distance out from the boat's own position, same spherical
|
|
1240
|
+
// approximation as everywhere else on this chart.
|
|
1241
|
+
function destinationPoint(pt, bearingRad, distanceM) {
|
|
1242
|
+
const delta = distanceM / EARTH_RADIUS_M;
|
|
1243
|
+
const phi1 = toRad(pt.lat);
|
|
1244
|
+
const lambda1 = toRad(pt.lon);
|
|
1245
|
+
const phi2 = Math.asin(Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(bearingRad));
|
|
1246
|
+
const lambda2 = lambda1 + Math.atan2(Math.sin(bearingRad) * Math.sin(delta) * Math.cos(phi1), Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2));
|
|
1247
|
+
return { lat: toDeg(phi2), lon: toDeg(lambda2) };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ---- Wind shift / tack-or-hold --------------------------------------
|
|
1251
|
+
// A lift/header indicator: is the current true wind direction shifted
|
|
1252
|
+
// toward this vessel's advantage on whichever tack it's on right now
|
|
1253
|
+
// (favors holding), or away from it (favors tacking onto the shift)?
|
|
1254
|
+
// Tracks a short rolling average of true wind direction as the
|
|
1255
|
+
// "baseline" — the same live SignalK feed the laylines use — since a
|
|
1256
|
+
// helm sailing closehauled keeps adjusting course to hold a steady angle
|
|
1257
|
+
// to the wind, so watching the boat's own heading wouldn't show a shift
|
|
1258
|
+
// that's already been steered around; watching the wind's own bearing
|
|
1259
|
+
// trend does.
|
|
1260
|
+
const WIND_HISTORY_WINDOW_MS = 3 * 60 * 1000; // rolling baseline span
|
|
1261
|
+
const WIND_HISTORY_MIN_SPAN_MS = 30 * 1000; // need at least this much history before calling a shift
|
|
1262
|
+
const WIND_SHIFT_DEADBAND_DEG = 4; // smaller than this reads as "steady", not a real shift
|
|
1263
|
+
let windHistory = []; // [{t, rad}], trimmed to WIND_HISTORY_WINDOW_MS
|
|
1264
|
+
|
|
1265
|
+
function normalizeSignedRad(a) {
|
|
1266
|
+
const twoPi = Math.PI * 2;
|
|
1267
|
+
let r = a % twoPi;
|
|
1268
|
+
if (r > Math.PI) r -= twoPi;
|
|
1269
|
+
if (r < -Math.PI) r += twoPi;
|
|
1270
|
+
return r;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Angles wrap, so a plain arithmetic mean is wrong near the wrap point —
|
|
1274
|
+
// average the unit vectors instead (standard circular mean).
|
|
1275
|
+
function circularMeanRad(radians) {
|
|
1276
|
+
let sumSin = 0;
|
|
1277
|
+
let sumCos = 0;
|
|
1278
|
+
radians.forEach((r) => {
|
|
1279
|
+
sumSin += Math.sin(r);
|
|
1280
|
+
sumCos += Math.cos(r);
|
|
1281
|
+
});
|
|
1282
|
+
return Math.atan2(sumSin, sumCos);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// null until there's a moving self position, a true wind direction, a
|
|
1286
|
+
// heading to determine tack from, and enough rolling history to call a
|
|
1287
|
+
// baseline — same "if available, silently not otherwise" spirit as the
|
|
1288
|
+
// laylines above.
|
|
1289
|
+
function computeWindShift() {
|
|
1290
|
+
if (!selfNav || selfNav.windDirTrueRad == null || selfNav.headingRad == null) return null;
|
|
1291
|
+
if (!windHistory.length) return null;
|
|
1292
|
+
const span = windHistory[windHistory.length - 1].t - windHistory[0].t;
|
|
1293
|
+
if (span < WIND_HISTORY_MIN_SPAN_MS) return null;
|
|
1294
|
+
const baselineRad = circularMeanRad(windHistory.map((s) => s.rad));
|
|
1295
|
+
const shiftRad = normalizeSignedRad(selfNav.windDirTrueRad - baselineRad);
|
|
1296
|
+
const shiftDeg = toDeg(shiftRad);
|
|
1297
|
+
// Wind source to the right of the bow (positive) = starboard tack.
|
|
1298
|
+
const tack = normalizeSignedRad(selfNav.windDirTrueRad - selfNav.headingRad) > 0 ? 'starboard' : 'port';
|
|
1299
|
+
let verdict = 'steady';
|
|
1300
|
+
if (Math.abs(shiftDeg) >= WIND_SHIFT_DEADBAND_DEG) {
|
|
1301
|
+
const isLift = tack === 'starboard' ? shiftDeg > 0 : shiftDeg < 0;
|
|
1302
|
+
verdict = isLift ? 'lift' : 'header';
|
|
1303
|
+
}
|
|
1304
|
+
return { tack, verdict, shiftDeg, twdDeg: (toDeg(selfNav.windDirTrueRad) + 360) % 360 };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function renderWindShift() {
|
|
1308
|
+
const result = computeWindShift();
|
|
1309
|
+
windShiftBar.hidden = !result;
|
|
1310
|
+
if (!result) return;
|
|
1311
|
+
windShiftTack.textContent = result.tack === 'starboard' ? 'Starboard' : 'Port';
|
|
1312
|
+
windShiftTwd.textContent = `${Math.round(result.twdDeg)}°`;
|
|
1313
|
+
windShiftValue.className = 'start-timer-value';
|
|
1314
|
+
if (result.verdict === 'steady') {
|
|
1315
|
+
windShiftValue.textContent = 'Steady';
|
|
1316
|
+
} else {
|
|
1317
|
+
windShiftValue.textContent = `${result.verdict === 'lift' ? 'Lift' : 'Header'} ${result.shiftDeg >= 0 ? '+' : ''}${Math.round(result.shiftDeg)}°`;
|
|
1318
|
+
windShiftValue.classList.add(result.verdict);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1203
1321
|
|
|
1204
1322
|
// Perpendicular distance from a point to the start-line segment (or to
|
|
1205
1323
|
// its nearest end, if the point doesn't fall between the two ends) — a
|
|
@@ -2225,6 +2343,27 @@
|
|
|
2225
2343
|
.addTo(chartLayerGroup);
|
|
2226
2344
|
});
|
|
2227
2345
|
|
|
2346
|
+
// Laylines for this vessel — where a further 10 minutes on each
|
|
2347
|
+
// close-hauled tack would put it, from wherever it is right now. Shown
|
|
2348
|
+
// only when there's actually a true wind direction and a moving SOG to
|
|
2349
|
+
// draw them from (not just a position); silently absent otherwise,
|
|
2350
|
+
// same "if available" spirit as the rest of this app's SignalK-fed
|
|
2351
|
+
// instruments. LAYLINE_TACK_ANGLE_DEG is a generic close-hauled
|
|
2352
|
+
// assumption, not this boat's own polar data — good enough for a rough
|
|
2353
|
+
// visual, not a precise VMG angle.
|
|
2354
|
+
const LAYLINE_TACK_ANGLE_DEG = 40;
|
|
2355
|
+
const LAYLINE_DURATION_S = 10 * 60;
|
|
2356
|
+
if (selfNav && selfNav.windDirTrueRad != null && selfNav.sogMs != null && selfNav.sogMs >= 0.25) {
|
|
2357
|
+
const distanceM = selfNav.sogMs * LAYLINE_DURATION_S;
|
|
2358
|
+
const tackAngleRad = toRad(LAYLINE_TACK_ANGLE_DEG);
|
|
2359
|
+
[selfNav.windDirTrueRad + tackAngleRad, selfNav.windDirTrueRad - tackAngleRad].forEach((bearingRad) => {
|
|
2360
|
+
const end = destinationPoint(selfNav, normalizeRad(bearingRad), distanceM);
|
|
2361
|
+
L.polyline([ll(selfNav), ll(end)], { color: 'var(--accent)', weight: 1.5, dashArray: '2,6' })
|
|
2362
|
+
.bindTooltip('<span style="color:var(--accent)">10min layline</span>', { sticky: true, className: 'mark-label' })
|
|
2363
|
+
.addTo(chartLayerGroup);
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2228
2367
|
let minT = Infinity;
|
|
2229
2368
|
let maxT = -Infinity;
|
|
2230
2369
|
boatsWithTrack.forEach((b) => {
|
|
@@ -2811,6 +2950,7 @@
|
|
|
2811
2950
|
classSection.hidden = !raceState;
|
|
2812
2951
|
raceImportSection.hidden = !raceState || !raceImportEnabled;
|
|
2813
2952
|
renderStartTimer();
|
|
2953
|
+
renderWindShift();
|
|
2814
2954
|
|
|
2815
2955
|
if (!raceState) {
|
|
2816
2956
|
emptyMsg.hidden = true;
|
|
@@ -3112,6 +3252,7 @@
|
|
|
3112
3252
|
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
3113
3253
|
if (!courseBody.hidden) {
|
|
3114
3254
|
renderChart();
|
|
3255
|
+
renderWindShift();
|
|
3115
3256
|
// The map may have been created (or last sized) while its container
|
|
3116
3257
|
// was hidden or a different size — Leaflet doesn't pick that up on
|
|
3117
3258
|
// its own.
|
|
@@ -3220,7 +3361,13 @@
|
|
|
3220
3361
|
|
|
3221
3362
|
async function refreshSelfNav() {
|
|
3222
3363
|
selfNav = await fetchSelfNav();
|
|
3364
|
+
if (selfNav && selfNav.windDirTrueRad != null) {
|
|
3365
|
+
const now = Date.now();
|
|
3366
|
+
windHistory.push({ t: now, rad: selfNav.windDirTrueRad });
|
|
3367
|
+
while (windHistory.length && now - windHistory[0].t > WIND_HISTORY_WINDOW_MS) windHistory.shift();
|
|
3368
|
+
}
|
|
3223
3369
|
renderStartTimer();
|
|
3370
|
+
renderWindShift();
|
|
3224
3371
|
}
|
|
3225
3372
|
|
|
3226
3373
|
async function init() {
|
package/public/index.html
CHANGED
|
@@ -159,6 +159,20 @@
|
|
|
159
159
|
<span id="courseStatusText" class="status"></span>
|
|
160
160
|
</div>
|
|
161
161
|
</div>
|
|
162
|
+
<div id="windShiftBar" class="start-timer-grid wind-shift-grid" hidden>
|
|
163
|
+
<div class="start-timer-tile">
|
|
164
|
+
<div class="start-timer-label">Wind shift</div>
|
|
165
|
+
<div id="windShiftValue" class="start-timer-value">--</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="start-timer-tile">
|
|
168
|
+
<div class="start-timer-label">Tack</div>
|
|
169
|
+
<div id="windShiftTack" class="start-timer-value">--</div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="start-timer-tile">
|
|
172
|
+
<div class="start-timer-label">True wind</div>
|
|
173
|
+
<div id="windShiftTwd" class="start-timer-value">--</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
162
176
|
<div class="chart-wrap">
|
|
163
177
|
<div id="courseChart" class="chart-map"></div>
|
|
164
178
|
<p id="chartEmptyMsg" class="empty chart-empty-msg" hidden>Add a course, then start the race, to see the chart here</p>
|
package/public/style.css
CHANGED
|
@@ -276,6 +276,18 @@ main {
|
|
|
276
276
|
color: var(--muted);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
.wind-shift-grid {
|
|
280
|
+
margin-bottom: 1rem;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.start-timer-value.lift {
|
|
284
|
+
color: var(--good);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.start-timer-value.header {
|
|
288
|
+
color: var(--bad);
|
|
289
|
+
}
|
|
290
|
+
|
|
279
291
|
.course-grid {
|
|
280
292
|
margin-top: 0.75rem;
|
|
281
293
|
display: flex;
|