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 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** — enter lat/lon for the start line, an ordered list of rounding
103
- marks, and the finish line (or click **Use my position** if you're sitting at that
104
- spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
105
- already placed from a chart plotter — and pick one to fill in its position; typing
106
- lat/lon directly works just as well). Every other known waypoint (not part of this
107
- race's own course) shows on the chart too, as a small dim pin with its name, so it's
108
- there to reference — or type into a mark's name field — even before it's used.
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.8.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(+optional "use my position") row, shared by start
1156
- // line, finish line, and mark entry. The name field autocompletes against
1157
- // existing SignalK waypoints (e.g. ones already placed on a chart
1158
- // plotter) — picking one fills in lat/lon (and the name) from it, so a
1159
- // start/mark/finish position doesn't have to be typed by hand if a
1160
- // waypoint for it already exists.
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 useHereBtn = document.createElement('button');
1195
- useHereBtn.type = 'button';
1196
- useHereBtn.className = 'secondary';
1197
- useHereBtn.textContent = 'Use my position';
1198
- useHereBtn.addEventListener('click', async () => {
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
- row.append(nameWrap, latInput, lonInput, useHereBtn);
1220
- return { row, nameInput, latInput, lonInput };
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;