signalk-race-control 0.8.0 → 0.9.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 +16 -7
- package/package.json +1 -1
- package/public/app.js +128 -13
- package/public/style.css +19 -0
package/README.md
CHANGED
|
@@ -99,13 +99,22 @@ and a live projected finishing order — during and after the race.
|
|
|
99
99
|
slow down or take a longer approach); negative means you're behind schedule to make
|
|
100
100
|
the line before the gun. Disappears once the race actually starts, since the
|
|
101
101
|
pre-start approach is moot by then.
|
|
102
|
-
- **Course & chart** —
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
- **Course & chart** — set the start line, an ordered list of rounding marks, and the
|
|
103
|
+
finish line by whichever's easiest: type lat/lon directly, type a name to autocomplete
|
|
104
|
+
against existing SignalK waypoints (e.g. ones already placed from a chart plotter) and
|
|
105
|
+
pick one to fill in its position, or use the position button — it reads **Pick on
|
|
106
|
+
map** while that point is still unset (arm it, then click the spot on the chart
|
|
107
|
+
below), and switches to **Use my position** once it has one, to re-centre it on
|
|
108
|
+
wherever you are right now instead. Setting a position this way (map click or self
|
|
109
|
+
position — not while still typing lat/lon by hand) also suggests a name for it from
|
|
110
|
+
OpenStreetMap, when there's a real charted place right there and the name field is
|
|
111
|
+
still empty; typing or picking a waypoint always wins over the suggestion. A mark's
|
|
112
|
+
own **Pick on map** only turns on once the start line has a position — before that the
|
|
113
|
+
chart has nothing to anchor its zoom to and is still showing the whole world, not much
|
|
114
|
+
use to click into (typed lat/lon, autocomplete, and "Use my position" all stay
|
|
115
|
+
available regardless). Every other known waypoint (not part of this race's own
|
|
116
|
+
course) shows on the chart too, as a small dim pin with its name, so it's there to
|
|
117
|
+
reference — or type into a mark's name field — even before it's used.
|
|
109
118
|
The chart itself is a real background map, like freeboard-sk's: it uses whatever
|
|
110
119
|
tile-based chart resource this SignalK server has registered, or falls back to public
|
|
111
120
|
OpenStreetMap + OpenSeaMap tiles when none is configured (same fallback freeboard-sk
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
let chartLayerGroup = null; // holds everything renderChart() redraws, cleared and rebuilt each call
|
|
36
36
|
let chartActiveRaceId; // which race's bounds were last auto-fit, so switching races re-fits once
|
|
37
37
|
let chartFitDone = false;
|
|
38
|
+
let mapPickTarget = null; // { latInput, lonInput, btn, onPositionChanged } while armed, else null
|
|
38
39
|
|
|
39
40
|
const raceSelect = document.getElementById('raceSelect');
|
|
40
41
|
const newRaceBtn = document.getElementById('newRaceBtn');
|
|
@@ -1152,12 +1153,18 @@
|
|
|
1152
1153
|
});
|
|
1153
1154
|
}
|
|
1154
1155
|
|
|
1155
|
-
// One name+lat+lon
|
|
1156
|
-
// line,
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1156
|
+
// One name+lat+lon+position-button row, shared by start line, finish
|
|
1157
|
+
// line, and mark entry. The name field autocompletes against existing
|
|
1158
|
+
// SignalK waypoints (e.g. ones already placed on a chart plotter) —
|
|
1159
|
+
// picking one fills in lat/lon (and the name) from it, so a position
|
|
1160
|
+
// doesn't have to be typed by hand if a waypoint for it already exists.
|
|
1161
|
+
// The one button toggles with whether this row already has a position:
|
|
1162
|
+
// "Pick on map" while empty (arm it, then click the chart below to fill
|
|
1163
|
+
// lat/lon from wherever was clicked), "Use my position" once it has one
|
|
1164
|
+
// (a quick way to re-centre it on wherever you are right now instead).
|
|
1165
|
+
// Mark rows' own map-picking gating (see updateMarkPickGating) is
|
|
1166
|
+
// recomputed on every position-setting action here, on any row, since a
|
|
1167
|
+
// start line change is typically what unlocks it.
|
|
1161
1168
|
function buildPointRow(point) {
|
|
1162
1169
|
const row = document.createElement('div');
|
|
1163
1170
|
row.className = 'course-point-row';
|
|
@@ -1191,15 +1198,47 @@
|
|
|
1191
1198
|
lonInput.placeholder = 'Lon';
|
|
1192
1199
|
lonInput.value = point ? point.lon : '';
|
|
1193
1200
|
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1201
|
+
const posBtn = document.createElement('button');
|
|
1202
|
+
posBtn.type = 'button';
|
|
1203
|
+
posBtn.className = 'secondary pick-on-map-btn';
|
|
1204
|
+
|
|
1205
|
+
function hasPosition() {
|
|
1206
|
+
return latInput.value !== '' && lonInput.value !== '';
|
|
1207
|
+
}
|
|
1208
|
+
function updatePosBtnLabel() {
|
|
1209
|
+
posBtn.textContent = hasPosition() ? 'Use my position' : 'Pick on map';
|
|
1210
|
+
}
|
|
1211
|
+
// Cheap enough to run on every keystroke — label state and mark
|
|
1212
|
+
// gating both just check whether lat/lon are non-empty.
|
|
1213
|
+
function notifyPositionChanged() {
|
|
1214
|
+
updatePosBtnLabel();
|
|
1215
|
+
updateMarkPickGating();
|
|
1216
|
+
}
|
|
1217
|
+
// The heavier, best-effort name suggestion only makes sense after a
|
|
1218
|
+
// real discrete action (a map click, a self-position fetch) — not on
|
|
1219
|
+
// every keystroke while someone's still typing lat/lon by hand.
|
|
1220
|
+
function handlePositionSet() {
|
|
1221
|
+
notifyPositionChanged();
|
|
1222
|
+
maybeSuggestNameFromPosition(nameInput, latInput.value, lonInput.value);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
latInput.addEventListener('input', notifyPositionChanged);
|
|
1226
|
+
lonInput.addEventListener('input', notifyPositionChanged);
|
|
1227
|
+
|
|
1228
|
+
posBtn.addEventListener('click', async () => {
|
|
1229
|
+
if (!hasPosition()) {
|
|
1230
|
+
if (mapPickTarget && mapPickTarget.btn === posBtn) {
|
|
1231
|
+
cancelMapPick();
|
|
1232
|
+
} else {
|
|
1233
|
+
armMapPick(latInput, lonInput, posBtn, nameInput.placeholder || 'this point', handlePositionSet);
|
|
1234
|
+
}
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1199
1237
|
const pos = await fetchSelfPosition();
|
|
1200
1238
|
if (pos) {
|
|
1201
1239
|
latInput.value = pos.lat;
|
|
1202
1240
|
lonInput.value = pos.lon;
|
|
1241
|
+
handlePositionSet();
|
|
1203
1242
|
} else {
|
|
1204
1243
|
setCourseStatus('Could not read a current position from SignalK.', true);
|
|
1205
1244
|
}
|
|
@@ -1213,11 +1252,73 @@
|
|
|
1213
1252
|
nameInput.value = wp.name;
|
|
1214
1253
|
latInput.value = wp.lat;
|
|
1215
1254
|
lonInput.value = wp.lon;
|
|
1255
|
+
notifyPositionChanged();
|
|
1216
1256
|
}
|
|
1217
1257
|
);
|
|
1218
1258
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1259
|
+
updatePosBtnLabel();
|
|
1260
|
+
row.append(nameWrap, latInput, lonInput, posBtn);
|
|
1261
|
+
return { row, nameInput, latInput, lonInput, pickBtn: posBtn, hasPosition };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Best-effort: suggests a name for an unnamed point from OpenStreetMap's
|
|
1265
|
+
// reverse geocoding once a position is actually set (by map click or
|
|
1266
|
+
// self-position — not while still typing lat/lon by hand), so a
|
|
1267
|
+
// start/finish pin or a rounding mark doesn't have to be named from
|
|
1268
|
+
// scratch when there's a real charted place right there (a headland, a
|
|
1269
|
+
// skerry, a harbour). Never overwrites a name already typed or already
|
|
1270
|
+
// filled in from picking a waypoint; silently does nothing on any
|
|
1271
|
+
// failure, same spirit as the VET/KTK/waypoint lookups elsewhere.
|
|
1272
|
+
async function maybeSuggestNameFromPosition(nameInput, lat, lon) {
|
|
1273
|
+
if (nameInput.value.trim()) return;
|
|
1274
|
+
try {
|
|
1275
|
+
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=16&addressdetails=0`);
|
|
1276
|
+
if (!res.ok) return;
|
|
1277
|
+
const data = await res.json();
|
|
1278
|
+
if (data && data.name && !nameInput.value.trim()) nameInput.value = data.name;
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
// best-effort only
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Arms "pick on map" mode: the next click on the chart fills this row's
|
|
1285
|
+
// lat/lon from wherever was clicked. Clicking the same button again, a
|
|
1286
|
+
// different row's pick button, or Escape all cancel it.
|
|
1287
|
+
function armMapPick(latInput, lonInput, btn, label, onPositionChanged) {
|
|
1288
|
+
cancelMapPick();
|
|
1289
|
+
mapPickTarget = { latInput, lonInput, btn, onPositionChanged };
|
|
1290
|
+
btn.classList.add('active');
|
|
1291
|
+
courseChart.classList.add('picking');
|
|
1292
|
+
setCourseStatus(`Click the map below to place "${label}".`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function cancelMapPick() {
|
|
1296
|
+
if (!mapPickTarget) return;
|
|
1297
|
+
mapPickTarget.btn.classList.remove('active');
|
|
1298
|
+
mapPickTarget = null;
|
|
1299
|
+
courseChart.classList.remove('picking');
|
|
1300
|
+
setCourseStatus('');
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Marks need somewhere on the map worth clicking into — before the start
|
|
1304
|
+
// line has a position, the chart has nothing to anchor its zoom to and
|
|
1305
|
+
// is still showing the whole world (see chartFitDone). This only ever
|
|
1306
|
+
// gates the "Pick on map" state of a mark's button, never "Use my
|
|
1307
|
+
// position" — once a mark already has its own position (typed by hand,
|
|
1308
|
+
// or picked from a waypoint), re-centring it on self is always fine
|
|
1309
|
+
// regardless of the start line.
|
|
1310
|
+
function startLineHasPosition() {
|
|
1311
|
+
return !!(startLineRefs && startLineRefs.some((r) => r.latInput.value !== '' && r.lonInput.value !== ''));
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function updateMarkPickGating() {
|
|
1315
|
+
const startOk = startLineHasPosition();
|
|
1316
|
+
markRefs.forEach((r) => {
|
|
1317
|
+
if (!r.pickBtn) return;
|
|
1318
|
+
const gated = !r.hasPosition() && !startOk;
|
|
1319
|
+
r.pickBtn.disabled = gated;
|
|
1320
|
+
r.pickBtn.title = gated ? 'Set the start line first — the map needs a position to zoom to before you can pick one for a mark.' : '';
|
|
1321
|
+
});
|
|
1221
1322
|
}
|
|
1222
1323
|
|
|
1223
1324
|
function renderMarkRows() {
|
|
@@ -1275,6 +1376,7 @@
|
|
|
1275
1376
|
function loadCourseFormFromRace() {
|
|
1276
1377
|
const c = (raceState && raceState.course) || { startLine: null, marks: [], finishLine: null };
|
|
1277
1378
|
|
|
1379
|
+
cancelMapPick();
|
|
1278
1380
|
startLineRefs = [buildPointRow(c.startLine ? c.startLine[0] : null), buildPointRow(c.startLine ? c.startLine[1] : null)];
|
|
1279
1381
|
startLineRefs[0].nameInput.placeholder = 'Pin end name';
|
|
1280
1382
|
startLineRefs[1].nameInput.placeholder = 'Committee boat end name';
|
|
@@ -1289,6 +1391,7 @@
|
|
|
1289
1391
|
|
|
1290
1392
|
markRefs = (c.marks || []).map((m) => buildMarkRow(m));
|
|
1291
1393
|
renderMarkRows();
|
|
1394
|
+
updateMarkPickGating();
|
|
1292
1395
|
setCourseStatus('');
|
|
1293
1396
|
}
|
|
1294
1397
|
|
|
@@ -1498,6 +1601,14 @@
|
|
|
1498
1601
|
chartMap = L.map(courseChart, { attributionControl: true }).setView([0, 0], 2);
|
|
1499
1602
|
chartLayerGroup = L.layerGroup().addTo(chartMap);
|
|
1500
1603
|
addChartBaseLayers(chartMap);
|
|
1604
|
+
chartMap.on('click', (e) => {
|
|
1605
|
+
if (!mapPickTarget) return;
|
|
1606
|
+
const target = mapPickTarget;
|
|
1607
|
+
target.latInput.value = e.latlng.lat.toFixed(6);
|
|
1608
|
+
target.lonInput.value = e.latlng.lng.toFixed(6);
|
|
1609
|
+
cancelMapPick();
|
|
1610
|
+
if (target.onPositionChanged) target.onPositionChanged();
|
|
1611
|
+
});
|
|
1501
1612
|
return chartMap;
|
|
1502
1613
|
}
|
|
1503
1614
|
|
|
@@ -2468,6 +2579,10 @@
|
|
|
2468
2579
|
addMarkBtn.addEventListener('click', () => {
|
|
2469
2580
|
markRefs.push(buildMarkRow(null));
|
|
2470
2581
|
renderMarkRows();
|
|
2582
|
+
updateMarkPickGating();
|
|
2583
|
+
});
|
|
2584
|
+
document.addEventListener('keydown', (e) => {
|
|
2585
|
+
if (e.key === 'Escape' && mapPickTarget) cancelMapPick();
|
|
2471
2586
|
});
|
|
2472
2587
|
saveCourseBtn.addEventListener('click', saveCourse);
|
|
2473
2588
|
|
package/public/style.css
CHANGED
|
@@ -300,6 +300,15 @@ main {
|
|
|
300
300
|
align-items: center;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
.course-point-row .course-name-autocomplete .suggestions {
|
|
304
|
+
/* The name field itself is only 7rem wide to fit inline in the row, but
|
|
305
|
+
the dropdown it opens shouldn't be squeezed to match — that made boat
|
|
306
|
+
and waypoint names unreadable. right:auto lets it grow past the
|
|
307
|
+
parent's edge instead of being pinned to it. */
|
|
308
|
+
right: auto;
|
|
309
|
+
min-width: 16rem;
|
|
310
|
+
}
|
|
311
|
+
|
|
303
312
|
.course-point-row .course-name-autocomplete {
|
|
304
313
|
position: relative;
|
|
305
314
|
width: 7rem;
|
|
@@ -330,6 +339,16 @@ main {
|
|
|
330
339
|
font-size: 0.75rem;
|
|
331
340
|
}
|
|
332
341
|
|
|
342
|
+
.course-point-row button.pick-on-map-btn.active {
|
|
343
|
+
background: var(--accent);
|
|
344
|
+
color: #04202e;
|
|
345
|
+
border-color: var(--accent);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.chart-map.picking {
|
|
349
|
+
cursor: crosshair;
|
|
350
|
+
}
|
|
351
|
+
|
|
333
352
|
.course-actions {
|
|
334
353
|
margin-top: 0.75rem;
|
|
335
354
|
display: flex;
|