signalk-race-control 0.11.0 → 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 +125 -1
- package/package.json +1 -1
- package/public/app.js +185 -25
- 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
|
|
|
@@ -2172,6 +2236,66 @@ module.exports = function (app) {
|
|
|
2172
2236
|
res.json({ currentRaceId: state.currentRaceId, races });
|
|
2173
2237
|
});
|
|
2174
2238
|
|
|
2239
|
+
// Proxies an Overpass API query (used by the course editor's OpenSeaMap
|
|
2240
|
+
// nav-mark search/snap) through this server rather than the browser
|
|
2241
|
+
// calling a public Overpass mirror directly. Two problems forced this:
|
|
2242
|
+
// overpass-api.de — the one mirror actually verified to carry real,
|
|
2243
|
+
// complete seamark data — sends no Access-Control-Allow-Origin header,
|
|
2244
|
+
// so a browser fetch to it is blocked outright regardless of whether
|
|
2245
|
+
// the request itself would succeed; and of the two mirrors that do
|
|
2246
|
+
// support CORS, one has been unreachable and the other has been
|
|
2247
|
+
// observed returning a confident HTTP 200 with zero elements for real,
|
|
2248
|
+
// densely-tagged areas. A server-to-server request isn't subject to
|
|
2249
|
+
// CORS at all, so this can freely use the mirror that actually works.
|
|
2250
|
+
const OVERPASS_ENDPOINTS = [
|
|
2251
|
+
'https://overpass-api.de/api/interpreter',
|
|
2252
|
+
'https://overpass.kumi.systems/api/interpreter',
|
|
2253
|
+
'https://overpass.osm.ch/api/interpreter'
|
|
2254
|
+
];
|
|
2255
|
+
router.get('/overpass', async (req, res) => {
|
|
2256
|
+
const query = req.query.data;
|
|
2257
|
+
if (!query) return res.status(400).json({ error: 'Missing data query parameter' });
|
|
2258
|
+
// overpass-api.de has the best data but is also the most rate-
|
|
2259
|
+
// limited/overloaded public instance — it can 504 one moment and
|
|
2260
|
+
// work fine a couple of seconds later. A valid-but-empty response
|
|
2261
|
+
// from a worse mirror is accepted only as a last resort (remembered,
|
|
2262
|
+
// not returned immediately), and the whole mirror list gets a second
|
|
2263
|
+
// pass after a short pause before actually giving up — otherwise a
|
|
2264
|
+
// single transient overload on the good mirror would get cached by
|
|
2265
|
+
// the client as "nothing charted here" for the rest of the session.
|
|
2266
|
+
let emptyResult = null;
|
|
2267
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
2268
|
+
for (const base of OVERPASS_ENDPOINTS) {
|
|
2269
|
+
try {
|
|
2270
|
+
const controller = new AbortController();
|
|
2271
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
2272
|
+
let r;
|
|
2273
|
+
try {
|
|
2274
|
+
// overpass-api.de specifically 406s a generic/default
|
|
2275
|
+
// User-Agent (confirmed: it rejects Node's own default one)
|
|
2276
|
+
// — identify the app explicitly, as any well-behaved
|
|
2277
|
+
// Overpass client should.
|
|
2278
|
+
r = await fetch(`${base}?data=${encodeURIComponent(query)}`, {
|
|
2279
|
+
signal: controller.signal,
|
|
2280
|
+
headers: { 'User-Agent': 'signalk-race-control (+https://github.com/joabakk/signalk-race-control)' }
|
|
2281
|
+
});
|
|
2282
|
+
} finally {
|
|
2283
|
+
clearTimeout(timer);
|
|
2284
|
+
}
|
|
2285
|
+
if (!r.ok) continue;
|
|
2286
|
+
const data = await r.json();
|
|
2287
|
+
if (!data || !Array.isArray(data.elements)) continue;
|
|
2288
|
+
if (data.elements.length) return res.json(data);
|
|
2289
|
+
emptyResult = data;
|
|
2290
|
+
} catch (e) {
|
|
2291
|
+
// try the next mirror
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (pass === 0) await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2295
|
+
}
|
|
2296
|
+
res.json(emptyResult || { elements: [] });
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2175
2299
|
router.post('/races', (req, res) => {
|
|
2176
2300
|
const name = ((req.body && req.body.name) || '').trim();
|
|
2177
2301
|
if (!name) {
|
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
|
|
@@ -1412,32 +1530,29 @@
|
|
|
1412
1530
|
// autocomplete and the pick-on-map snap. Silently returns nothing on
|
|
1413
1531
|
// any failure or timeout, same spirit as the reverse-geocode name
|
|
1414
1532
|
// suggestion.
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
// click the way this used to run), so the extra latency is worth it.
|
|
1423
|
-
const OVERPASS_ENDPOINTS = ['https://overpass.kumi.systems/api/interpreter', 'https://overpass.osm.ch/api/interpreter'];
|
|
1533
|
+
// Goes through this plugin's own server (see the /overpass route)
|
|
1534
|
+
// rather than calling a public Overpass mirror directly from here: the
|
|
1535
|
+
// mirror with real, complete seamark data (overpass-api.de) sends no
|
|
1536
|
+
// CORS headers, so a browser fetch to it is blocked outright — a
|
|
1537
|
+
// server-to-server request isn't subject to CORS at all. The server
|
|
1538
|
+
// also handles falling back across mirrors, so this stays a single
|
|
1539
|
+
// same-origin call.
|
|
1424
1540
|
async function queryOverpass(overpassQuery) {
|
|
1425
|
-
|
|
1426
|
-
for (const base of OVERPASS_ENDPOINTS) {
|
|
1541
|
+
try {
|
|
1427
1542
|
const controller = new AbortController();
|
|
1428
|
-
const timer = setTimeout(() => controller.abort(),
|
|
1543
|
+
const timer = setTimeout(() => controller.abort(), 90000);
|
|
1544
|
+
let res;
|
|
1429
1545
|
try {
|
|
1430
|
-
|
|
1431
|
-
if (!res.ok) continue;
|
|
1432
|
-
const data = await res.json();
|
|
1433
|
-
if (data && Array.isArray(data.elements)) elements.push(...data.elements);
|
|
1434
|
-
} catch (e) {
|
|
1435
|
-
// try the next endpoint
|
|
1546
|
+
res = await fetch(`${API}/overpass?data=${encodeURIComponent(overpassQuery)}`, { signal: controller.signal });
|
|
1436
1547
|
} finally {
|
|
1437
1548
|
clearTimeout(timer);
|
|
1438
1549
|
}
|
|
1550
|
+
if (!res.ok) return [];
|
|
1551
|
+
const data = await res.json();
|
|
1552
|
+
return data && Array.isArray(data.elements) ? data.elements : [];
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
return [];
|
|
1439
1555
|
}
|
|
1440
|
-
return elements;
|
|
1441
1556
|
}
|
|
1442
1557
|
|
|
1443
1558
|
function seamarkElementsToResults(elements) {
|
|
@@ -1465,8 +1580,24 @@
|
|
|
1465
1580
|
if (seamarkCacheBounds && seamarkCacheBounds.contains(bounds)) return;
|
|
1466
1581
|
}
|
|
1467
1582
|
const padded = bounds.pad(0.5);
|
|
1468
|
-
|
|
1469
|
-
|
|
1583
|
+
// The actual Overpass query is clamped to a sane max span around the
|
|
1584
|
+
// padded area's center — a chart zoomed out to show a whole region
|
|
1585
|
+
// (easy to end up at after an auto-fit spanning far-apart boat tracks)
|
|
1586
|
+
// would otherwise turn this into a practically unbounded query, whose
|
|
1587
|
+
// out-body cap then gets consumed by whatever Overpass returns first
|
|
1588
|
+
// rather than the marks actually near what's being searched/picked.
|
|
1589
|
+
// seamarkCacheBounds still tracks the full (unclamped) padded area
|
|
1590
|
+
// below so a wide view isn't re-queried on every subsequent call.
|
|
1591
|
+
const MAX_SPAN_DEG = 1; // ~110km of latitude — generous for any single race course
|
|
1592
|
+
const center = padded.getCenter();
|
|
1593
|
+
const latSpan = Math.min(padded.getNorth() - padded.getSouth(), MAX_SPAN_DEG);
|
|
1594
|
+
const lonSpan = Math.min(padded.getEast() - padded.getWest(), MAX_SPAN_DEG);
|
|
1595
|
+
const queryBounds = L.latLngBounds(
|
|
1596
|
+
[center.lat - latSpan / 2, center.lng - lonSpan / 2],
|
|
1597
|
+
[center.lat + latSpan / 2, center.lng + lonSpan / 2]
|
|
1598
|
+
);
|
|
1599
|
+
const bbox = `${queryBounds.getSouth()},${queryBounds.getWest()},${queryBounds.getNorth()},${queryBounds.getEast()}`;
|
|
1600
|
+
const overpassQuery = `[out:json][timeout:15];node["seamark:type"](${bbox});out body 1000;`;
|
|
1470
1601
|
seamarkCacheFetch = queryOverpass(overpassQuery);
|
|
1471
1602
|
let elements;
|
|
1472
1603
|
try {
|
|
@@ -2212,6 +2343,27 @@
|
|
|
2212
2343
|
.addTo(chartLayerGroup);
|
|
2213
2344
|
});
|
|
2214
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
|
+
|
|
2215
2367
|
let minT = Infinity;
|
|
2216
2368
|
let maxT = -Infinity;
|
|
2217
2369
|
boatsWithTrack.forEach((b) => {
|
|
@@ -2798,6 +2950,7 @@
|
|
|
2798
2950
|
classSection.hidden = !raceState;
|
|
2799
2951
|
raceImportSection.hidden = !raceState || !raceImportEnabled;
|
|
2800
2952
|
renderStartTimer();
|
|
2953
|
+
renderWindShift();
|
|
2801
2954
|
|
|
2802
2955
|
if (!raceState) {
|
|
2803
2956
|
emptyMsg.hidden = true;
|
|
@@ -3099,6 +3252,7 @@
|
|
|
3099
3252
|
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
3100
3253
|
if (!courseBody.hidden) {
|
|
3101
3254
|
renderChart();
|
|
3255
|
+
renderWindShift();
|
|
3102
3256
|
// The map may have been created (or last sized) while its container
|
|
3103
3257
|
// was hidden or a different size — Leaflet doesn't pick that up on
|
|
3104
3258
|
// its own.
|
|
@@ -3207,7 +3361,13 @@
|
|
|
3207
3361
|
|
|
3208
3362
|
async function refreshSelfNav() {
|
|
3209
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
|
+
}
|
|
3210
3369
|
renderStartTimer();
|
|
3370
|
+
renderWindShift();
|
|
3211
3371
|
}
|
|
3212
3372
|
|
|
3213
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;
|