signalk-race-control 0.8.0 → 0.10.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
@@ -19,9 +19,11 @@ and a live projected finishing order — during and after the race.
19
19
  - **Individual start times** — override the race's single start time for one boat at
20
20
  a time (in its own **Start time** column, right next to Finish time): for a
21
21
  staggered/pursuit start, or to correct a boat that didn't actually get away with
22
- the fleet. Left blank, a boat just uses the race's own start time as before.
23
- Starting or resetting the race clears every boat's override along with its finish
24
- time, ready for a clean re-run.
22
+ the fleet. Left blank, a boat just uses the race's own start time — shown there in
23
+ a dimmed/italic style once the race starts, so it reads as "racing, using the fleet
24
+ start" rather than as not having started; setting its own time (or clicking **Now**)
25
+ switches it to a real override, shown normally. Starting or resetting the race clears
26
+ every boat's override along with its finish time, ready for a clean re-run.
25
27
  - **Add/remove boats explicitly** — type a name (autocompletes against the VET
26
28
  register, the cross-race boat registry, and any live AIS/self vessel — matching
27
29
  anywhere in the name, not just the start) and click **Add Boat**. Boats aren't
@@ -99,18 +101,41 @@ and a live projected finishing order — during and after the race.
99
101
  slow down or take a longer approach); negative means you're behind schedule to make
100
102
  the line before the gun. Disappears once the race actually starts, since the
101
103
  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.
104
+ - **Course & chart** — set the start line, an ordered list of rounding marks, and the
105
+ finish line by whichever's easiest: type lat/lon directly, type a name to autocomplete
106
+ against existing SignalK waypoints (e.g. ones already placed from a chart plotter) — or
107
+ a real navigation aid's name (a buoy, beacon, light, mark — OpenSeaMap's own data,
108
+ searched live) within whatever area the chart is currently showing (tagged
109
+ "OpenSeaMap" to tell it apart from a saved waypoint) — and pick one to fill in its
110
+ position, or use the position button — it reads **Pick on
111
+ map** while that point is still unset (arm it, then click the spot on the chart
112
+ below — a click is rarely pixel-perfect on the actual mark, so if there's a real
113
+ navigation aid within 30m of wherever was clicked, it asks whether to snap to that
114
+ aid's exact position and name instead of the raw click), and switches to **Use my
115
+ position** once it has one, to re-centre it on wherever you are right now instead.
116
+ Setting a position this way (map click or self
117
+ position — not while still typing lat/lon by hand) also suggests a name for it from
118
+ OpenStreetMap, when there's a real charted place right there and the name field is
119
+ still empty; typing or picking a waypoint always wins over the suggestion. A mark's
120
+ own **Pick on map** only turns on once the start line has a position — before that the
121
+ chart has nothing to anchor its zoom to and is still showing the whole world, not much
122
+ use to click into (typed lat/lon, autocomplete, and "Use my position" all stay
123
+ available regardless). Every other known waypoint (not part of this race's own
124
+ course) shows on the chart too, as a small dim pin with its name, so it's there to
125
+ reference — or type into a mark's name field — even before it's used.
109
126
  The chart itself is a real background map, like freeboard-sk's: it uses whatever
110
- tile-based chart resource this SignalK server has registered, or falls back to public
111
- OpenStreetMap + OpenSeaMap tiles when none is configured (same fallback freeboard-sk
112
- itself uses). Overlaid on it is each AIS-tracked boat's recorded track for the current
113
- race — drag the **replay** slider to step back
127
+ tile-based chart resource(s) this SignalK server has registered, or falls back to
128
+ public OpenStreetMap tiles when none is configured (same fallback freeboard-sk itself
129
+ uses). Registering more than one chart offers them as alternative base layers via the
130
+ layer switcher (top right of the map) rather than stacking them all at once — a plain
131
+ street map on top would otherwise bury a real nautical chart underneath it. When one of
132
+ them is Eniro's plain map (not "Eniro sjø"), that's the one shown by default — the
133
+ easiest to actually read at a glance; otherwise it's whichever one loaded first.
134
+ OpenSeaMap's
135
+ navigation aids (buoys, lights, marks) are offered there too, as an overlay on top of
136
+ whichever base chart is active, on by default. Overlaid on all of that is each
137
+ AIS-tracked boat's recorded track for the current race — drag the **replay** slider to
138
+ step back
114
139
  through it, or leave it on **Live**. Click **Play** to have it step through on its own
115
140
  instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
116
141
  from the earliest recorded position when played from Live (there's nothing to play
package/index.js CHANGED
@@ -509,6 +509,7 @@ tbody tr.dnf td { color: var(--muted); }
509
509
  .vs-self .self-tag { font-size: 0.7rem; letter-spacing: 0.04em; color: var(--muted); font-style: italic; }
510
510
  .finish-cell { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
511
511
  .finish-time-input, .start-time-input { padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; font-variant-numeric: tabular-nums; }
512
+ .start-time-input.inherited-value { color: var(--muted); font-style: italic; }
512
513
  .finish-now-btn, .finish-clear-btn, .finish-dnf-btn, .undo-dnf-btn, .remove-boat-btn { padding: 0.3rem 0.6rem; font-size: 0.8rem; background: transparent; color: var(--text); }
513
514
  .finish-dnf-btn { border-color: var(--bad); color: var(--bad); }
514
515
  .dnf-pos { display: block; font-size: 0.7rem; color: var(--muted); }
@@ -1367,9 +1368,15 @@ tbody tr.dnf td { color: var(--muted); }
1367
1368
  }
1368
1369
  syncVetSelectValue(row, b.tcf);
1369
1370
  }
1371
+ // See the equivalent block in app.js's render() — a boat with no
1372
+ // start time of its own already starts with the fleet, so the
1373
+ // input shows the inherited race start time (dimmed) instead of
1374
+ // blank, without turning it into a real per-boat override.
1375
+ var effectiveStart = b.startTime != null ? b.startTime : race.startTime;
1370
1376
  if (document.activeElement !== row.startTimeInput) {
1371
- row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
1377
+ row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(effectiveStart) : tsToTimeInputValue(effectiveStart);
1372
1378
  }
1379
+ row.startTimeInput.classList.toggle('inherited-value', b.startTime == null && race.startTime != null);
1373
1380
  var canStart = !!race.startTime;
1374
1381
  row.startTimeInput.disabled = !canStart;
1375
1382
  row.startNowBtn.disabled = !canStart;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.8.0",
3
+ "version": "0.10.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');
@@ -1071,15 +1072,24 @@
1071
1072
  // instances (one per course point row) can coexist without stepping on
1072
1073
  // each other the way sharing the boat autocomplete's module-level state
1073
1074
  // would.
1074
- function attachAutocomplete(input, dropdown, getPool, onSelect) {
1075
+ // getExtra (optional) — async (query) => [{name, lat, lon, source}] —
1076
+ // supplements getPool's synchronous, immediate matches with a slower
1077
+ // source merged in once it resolves (used for the course-point rows'
1078
+ // live OpenStreetMap search; see searchOsmPlaces). Debounced on its own
1079
+ // so the instant local matches aren't held up waiting for it, and
1080
+ // request-numbered so a slow older lookup can't clobber a newer one.
1081
+ function attachAutocomplete(input, dropdown, getPool, onSelect, getExtra) {
1075
1082
  let items = [];
1076
1083
  let activeIndex = -1;
1084
+ let extraRequestId = 0;
1085
+ let extraDebounceTimer = null;
1077
1086
 
1078
1087
  function hide() {
1079
1088
  dropdown.hidden = true;
1080
1089
  dropdown.innerHTML = '';
1081
1090
  items = [];
1082
1091
  activeIndex = -1;
1092
+ clearTimeout(extraDebounceTimer);
1083
1093
  }
1084
1094
  function renderActive() {
1085
1095
  Array.from(dropdown.children).forEach((el, i) => el.classList.toggle('active', i === activeIndex));
@@ -1088,15 +1098,7 @@
1088
1098
  onSelect(item);
1089
1099
  hide();
1090
1100
  }
1091
- function showFor(query) {
1092
- const q = query.trim().toLowerCase();
1093
- if (!q) {
1094
- hide();
1095
- return;
1096
- }
1097
- const matches = getPool()
1098
- .filter((it) => it.name.toLowerCase().includes(q))
1099
- .slice(0, 20);
1101
+ function renderMatches(matches, q) {
1100
1102
  items = matches;
1101
1103
  activeIndex = -1;
1102
1104
  if (!matches.length) {
@@ -1118,6 +1120,7 @@
1118
1120
  '</mark>' +
1119
1121
  escapeHtml(item.name.slice(idx + q.length));
1120
1122
  }
1123
+ if (item.source === 'osm') div.innerHTML += '<span class="suggestion-source">OpenSeaMap</span>';
1121
1124
  // mousedown (not click) fires before the input's blur, same reason
1122
1125
  // as the boat-name suggestions above.
1123
1126
  div.addEventListener('mousedown', (e) => {
@@ -1128,6 +1131,30 @@
1128
1131
  });
1129
1132
  dropdown.hidden = false;
1130
1133
  }
1134
+ function showFor(query) {
1135
+ const q = query.trim().toLowerCase();
1136
+ clearTimeout(extraDebounceTimer);
1137
+ if (!q) {
1138
+ hide();
1139
+ return;
1140
+ }
1141
+ const localMatches = getPool()
1142
+ .filter((it) => it.name.toLowerCase().includes(q))
1143
+ .slice(0, 20);
1144
+ renderMatches(localMatches, q);
1145
+ if (!getExtra) return;
1146
+ extraDebounceTimer = setTimeout(() => {
1147
+ const requestId = ++extraRequestId;
1148
+ getExtra(query)
1149
+ .then((extraItems) => {
1150
+ if (requestId !== extraRequestId || !extraItems || !extraItems.length) return;
1151
+ const seen = new Set(localMatches.map((m) => m.name.toLowerCase()));
1152
+ const merged = localMatches.concat(extraItems.filter((it) => !seen.has(it.name.toLowerCase()))).slice(0, 20);
1153
+ renderMatches(merged, q);
1154
+ })
1155
+ .catch(() => {});
1156
+ }, 400);
1157
+ }
1131
1158
 
1132
1159
  input.addEventListener('input', () => showFor(input.value));
1133
1160
  input.addEventListener('focus', () => {
@@ -1152,12 +1179,105 @@
1152
1179
  });
1153
1180
  }
1154
1181
 
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.
1182
+ // Best-effort: searches OpenSeaMap's own underlying data — OSM's
1183
+ // seamark:* tags, the same data its tiles are rendered from, queried
1184
+ // via the Overpass API — for named navigation aids (buoys, beacons,
1185
+ // lights, marks) within the chart's current visible area. Used to
1186
+ // supplement the course editor's waypoint autocomplete: a rounding mark
1187
+ // is far more likely to already be a real charted navigation aid than
1188
+ // an arbitrary place name, so this is scoped to seamark:type features
1189
+ // specifically rather than OSM place names in general. Silently returns
1190
+ // nothing on any failure, timeout, or when the chart hasn't been
1191
+ // created yet (course section not expanded) — same spirit as the
1192
+ // reverse-geocode name suggestion.
1193
+ // Public Overpass mirrors are individually flaky (rate limits, regional-
1194
+ // only data, outages) — tried one at a time, in order, falling through
1195
+ // to the next on any failure or timeout. Deliberately sequential, not
1196
+ // raced in parallel: a fast mirror with an incomplete regional extract
1197
+ // would otherwise "win" with a confident-looking empty result and mask
1198
+ // a real answer a fuller (but slower) mirror would have found. Returns
1199
+ // [] if every mirror fails.
1200
+ const OVERPASS_ENDPOINTS = ['https://overpass.kumi.systems/api/interpreter', 'https://overpass.osm.ch/api/interpreter'];
1201
+ async function queryOverpass(overpassQuery) {
1202
+ for (const base of OVERPASS_ENDPOINTS) {
1203
+ const controller = new AbortController();
1204
+ const timer = setTimeout(() => controller.abort(), 7000);
1205
+ try {
1206
+ const res = await fetch(`${base}?data=${encodeURIComponent(overpassQuery)}`, { signal: controller.signal });
1207
+ if (!res.ok) continue;
1208
+ const data = await res.json();
1209
+ if (data && Array.isArray(data.elements)) return data.elements;
1210
+ } catch (e) {
1211
+ // try the next endpoint
1212
+ } finally {
1213
+ clearTimeout(timer);
1214
+ }
1215
+ }
1216
+ return [];
1217
+ }
1218
+
1219
+ function seamarkElementsToResults(elements) {
1220
+ const seen = new Set();
1221
+ const results = [];
1222
+ elements.forEach((el) => {
1223
+ const name = el.tags && (el.tags['seamark:name'] || el.tags.name);
1224
+ if (!name || typeof el.lat !== 'number' || seen.has(name.toLowerCase())) return;
1225
+ seen.add(name.toLowerCase());
1226
+ results.push({ name, lat: el.lat, lon: el.lon, source: 'osm' });
1227
+ });
1228
+ return results;
1229
+ }
1230
+
1231
+ async function searchOsmPlaces(query) {
1232
+ const q = query.trim();
1233
+ if (!q || !chartMap) return [];
1234
+ const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1235
+ const b = chartMap.getBounds();
1236
+ const bbox = `${b.getSouth()},${b.getWest()},${b.getNorth()},${b.getEast()}`;
1237
+ const overpassQuery =
1238
+ `[out:json][timeout:9];(node["seamark:type"]["seamark:name"~"${escaped}",i](${bbox});` +
1239
+ `node["seamark:type"]["name"~"${escaped}",i](${bbox}););out body 8;`;
1240
+ return seamarkElementsToResults(await queryOverpass(overpassQuery));
1241
+ }
1242
+
1243
+ // Used right after a map click: is there a real charted navigation aid
1244
+ // within snapping distance of wherever was clicked? A click is rarely
1245
+ // pixel-perfect on the actual mark, so offering to snap to the nearest
1246
+ // one (rather than silently using the imprecise raw click) means the
1247
+ // recorded position is exactly right when there's something real there
1248
+ // to be exact about.
1249
+ const SNAP_RADIUS_M = 30;
1250
+ async function findNearbySeamark(lat, lon) {
1251
+ if (!chartMap) return null;
1252
+ const overpassQuery = `[out:json][timeout:9];node(around:${SNAP_RADIUS_M},${lat},${lon})["seamark:type"];out body 5;`;
1253
+ const elements = await queryOverpass(overpassQuery);
1254
+ const candidates = seamarkElementsToResults(elements);
1255
+ if (!candidates.length) return null;
1256
+ candidates.sort((a, b) => distanceMeters(lat, lon, a.lat, a.lon) - distanceMeters(lat, lon, b.lat, b.lon));
1257
+ return candidates[0];
1258
+ }
1259
+
1260
+ function distanceMeters(lat1, lon1, lat2, lon2) {
1261
+ const R = 6371000;
1262
+ const rad = Math.PI / 180;
1263
+ const dLat = (lat2 - lat1) * rad;
1264
+ const dLon = (lon2 - lon1) * rad;
1265
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2;
1266
+ return 2 * R * Math.asin(Math.sqrt(a));
1267
+ }
1268
+
1269
+ // One name+lat+lon+position-button row, shared by start line, finish
1270
+ // line, and mark entry. The name field autocompletes against existing
1271
+ // SignalK waypoints (e.g. ones already placed on a chart plotter) —
1272
+ // picking one fills in lat/lon (and the name) from it, so a position
1273
+ // doesn't have to be typed by hand if a waypoint for it already exists.
1274
+ // The one button toggles with whether this row already has a position:
1275
+ // "Pick on map" while empty (arm it, then click the chart below to fill
1276
+ // lat/lon from wherever was clicked), "Use my position" once it has one
1277
+ // (a quick way to re-centre it on wherever you are right now instead).
1278
+ // Mark rows' own map-picking gating (see updateMarkPickGating) is
1279
+ // recomputed on every position-setting action here, on any row, since a
1280
+ // start line change is typically what unlocks it.
1161
1281
  function buildPointRow(point) {
1162
1282
  const row = document.createElement('div');
1163
1283
  row.className = 'course-point-row';
@@ -1191,15 +1311,47 @@
1191
1311
  lonInput.placeholder = 'Lon';
1192
1312
  lonInput.value = point ? point.lon : '';
1193
1313
 
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 () => {
1314
+ const posBtn = document.createElement('button');
1315
+ posBtn.type = 'button';
1316
+ posBtn.className = 'secondary pick-on-map-btn';
1317
+
1318
+ function hasPosition() {
1319
+ return latInput.value !== '' && lonInput.value !== '';
1320
+ }
1321
+ function updatePosBtnLabel() {
1322
+ posBtn.textContent = hasPosition() ? 'Use my position' : 'Pick on map';
1323
+ }
1324
+ // Cheap enough to run on every keystroke — label state and mark
1325
+ // gating both just check whether lat/lon are non-empty.
1326
+ function notifyPositionChanged() {
1327
+ updatePosBtnLabel();
1328
+ updateMarkPickGating();
1329
+ }
1330
+ // The heavier, best-effort name suggestion only makes sense after a
1331
+ // real discrete action (a map click, a self-position fetch) — not on
1332
+ // every keystroke while someone's still typing lat/lon by hand.
1333
+ function handlePositionSet() {
1334
+ notifyPositionChanged();
1335
+ maybeSuggestNameFromPosition(nameInput, latInput.value, lonInput.value);
1336
+ }
1337
+
1338
+ latInput.addEventListener('input', notifyPositionChanged);
1339
+ lonInput.addEventListener('input', notifyPositionChanged);
1340
+
1341
+ posBtn.addEventListener('click', async () => {
1342
+ if (!hasPosition()) {
1343
+ if (mapPickTarget && mapPickTarget.btn === posBtn) {
1344
+ cancelMapPick();
1345
+ } else {
1346
+ armMapPick(latInput, lonInput, nameInput, posBtn, nameInput.placeholder || 'this point', handlePositionSet);
1347
+ }
1348
+ return;
1349
+ }
1199
1350
  const pos = await fetchSelfPosition();
1200
1351
  if (pos) {
1201
1352
  latInput.value = pos.lat;
1202
1353
  lonInput.value = pos.lon;
1354
+ handlePositionSet();
1203
1355
  } else {
1204
1356
  setCourseStatus('Could not read a current position from SignalK.', true);
1205
1357
  }
@@ -1213,11 +1365,74 @@
1213
1365
  nameInput.value = wp.name;
1214
1366
  latInput.value = wp.lat;
1215
1367
  lonInput.value = wp.lon;
1216
- }
1368
+ notifyPositionChanged();
1369
+ },
1370
+ searchOsmPlaces
1217
1371
  );
1218
1372
 
1219
- row.append(nameWrap, latInput, lonInput, useHereBtn);
1220
- return { row, nameInput, latInput, lonInput };
1373
+ updatePosBtnLabel();
1374
+ row.append(nameWrap, latInput, lonInput, posBtn);
1375
+ return { row, nameInput, latInput, lonInput, pickBtn: posBtn, hasPosition };
1376
+ }
1377
+
1378
+ // Best-effort: suggests a name for an unnamed point from OpenStreetMap's
1379
+ // reverse geocoding once a position is actually set (by map click or
1380
+ // self-position — not while still typing lat/lon by hand), so a
1381
+ // start/finish pin or a rounding mark doesn't have to be named from
1382
+ // scratch when there's a real charted place right there (a headland, a
1383
+ // skerry, a harbour). Never overwrites a name already typed or already
1384
+ // filled in from picking a waypoint; silently does nothing on any
1385
+ // failure, same spirit as the VET/KTK/waypoint lookups elsewhere.
1386
+ async function maybeSuggestNameFromPosition(nameInput, lat, lon) {
1387
+ if (nameInput.value.trim()) return;
1388
+ try {
1389
+ const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=16&addressdetails=0`);
1390
+ if (!res.ok) return;
1391
+ const data = await res.json();
1392
+ if (data && data.name && !nameInput.value.trim()) nameInput.value = data.name;
1393
+ } catch (e) {
1394
+ // best-effort only
1395
+ }
1396
+ }
1397
+
1398
+ // Arms "pick on map" mode: the next click on the chart fills this row's
1399
+ // lat/lon from wherever was clicked. Clicking the same button again, a
1400
+ // different row's pick button, or Escape all cancel it.
1401
+ function armMapPick(latInput, lonInput, nameInput, btn, label, onPositionChanged) {
1402
+ cancelMapPick();
1403
+ mapPickTarget = { latInput, lonInput, nameInput, btn, onPositionChanged };
1404
+ btn.classList.add('active');
1405
+ courseChart.classList.add('picking');
1406
+ setCourseStatus(`Click the map below to place "${label}".`);
1407
+ }
1408
+
1409
+ function cancelMapPick() {
1410
+ if (!mapPickTarget) return;
1411
+ mapPickTarget.btn.classList.remove('active');
1412
+ mapPickTarget = null;
1413
+ courseChart.classList.remove('picking');
1414
+ setCourseStatus('');
1415
+ }
1416
+
1417
+ // Marks need somewhere on the map worth clicking into — before the start
1418
+ // line has a position, the chart has nothing to anchor its zoom to and
1419
+ // is still showing the whole world (see chartFitDone). This only ever
1420
+ // gates the "Pick on map" state of a mark's button, never "Use my
1421
+ // position" — once a mark already has its own position (typed by hand,
1422
+ // or picked from a waypoint), re-centring it on self is always fine
1423
+ // regardless of the start line.
1424
+ function startLineHasPosition() {
1425
+ return !!(startLineRefs && startLineRefs.some((r) => r.latInput.value !== '' && r.lonInput.value !== ''));
1426
+ }
1427
+
1428
+ function updateMarkPickGating() {
1429
+ const startOk = startLineHasPosition();
1430
+ markRefs.forEach((r) => {
1431
+ if (!r.pickBtn) return;
1432
+ const gated = !r.hasPosition() && !startOk;
1433
+ r.pickBtn.disabled = gated;
1434
+ 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.' : '';
1435
+ });
1221
1436
  }
1222
1437
 
1223
1438
  function renderMarkRows() {
@@ -1275,6 +1490,7 @@
1275
1490
  function loadCourseFormFromRace() {
1276
1491
  const c = (raceState && raceState.course) || { startLine: null, marks: [], finishLine: null };
1277
1492
 
1493
+ cancelMapPick();
1278
1494
  startLineRefs = [buildPointRow(c.startLine ? c.startLine[0] : null), buildPointRow(c.startLine ? c.startLine[1] : null)];
1279
1495
  startLineRefs[0].nameInput.placeholder = 'Pin end name';
1280
1496
  startLineRefs[1].nameInput.placeholder = 'Committee boat end name';
@@ -1289,6 +1505,7 @@
1289
1505
 
1290
1506
  markRefs = (c.marks || []).map((m) => buildMarkRow(m));
1291
1507
  renderMarkRows();
1508
+ updateMarkPickGating();
1292
1509
  setCourseStatus('');
1293
1510
  }
1294
1511
 
@@ -1498,14 +1715,44 @@
1498
1715
  chartMap = L.map(courseChart, { attributionControl: true }).setView([0, 0], 2);
1499
1716
  chartLayerGroup = L.layerGroup().addTo(chartMap);
1500
1717
  addChartBaseLayers(chartMap);
1718
+ chartMap.on('click', async (e) => {
1719
+ if (!mapPickTarget) return;
1720
+ const target = mapPickTarget;
1721
+ const clickedLat = e.latlng.lat;
1722
+ const clickedLon = e.latlng.lng;
1723
+ target.latInput.value = clickedLat.toFixed(6);
1724
+ target.lonInput.value = clickedLon.toFixed(6);
1725
+ cancelMapPick();
1726
+ if (target.onPositionChanged) target.onPositionChanged();
1727
+
1728
+ // A click is rarely pixel-perfect on the actual mark — if there's a
1729
+ // real charted navigation aid right where this one landed, ask
1730
+ // before quietly using the imprecise raw click instead.
1731
+ const nearby = await findNearbySeamark(clickedLat, clickedLon);
1732
+ if (!nearby) return;
1733
+ if (!confirm(`Snap to nearby mark "${nearby.name}"?`)) return;
1734
+ target.latInput.value = nearby.lat.toFixed(6);
1735
+ target.lonInput.value = nearby.lon.toFixed(6);
1736
+ if (target.nameInput && !target.nameInput.value.trim()) target.nameInput.value = nearby.name;
1737
+ if (target.onPositionChanged) target.onPositionChanged();
1738
+ });
1501
1739
  return chartMap;
1502
1740
  }
1503
1741
 
1504
- // Prefers whatever tile-based chart resource this SignalK server has
1742
+ // Prefers whatever tile-based chart resource(s) this SignalK server has
1505
1743
  // registered (e.g. a locally cached raster chart) — same as freeboard-sk
1506
- // itself would use. Falls back to public OpenStreetMap + OpenSeaMap
1507
- // tiles when none is configured, which is also freeboard-sk's own
1508
- // fallback with no chart provider installed.
1744
+ // itself would use. Falls back to public OpenStreetMap tiles when none
1745
+ // is configured, which is also freeboard-sk's own fallback with no
1746
+ // chart provider installed. When more than one chart is registered
1747
+ // they're offered as alternative base layers (only one shown at a time,
1748
+ // picked via the layer control) rather than stacked on top of each
1749
+ // other — stacking silently buried whichever chart happened to load
1750
+ // last under whatever loaded after it, e.g. a plain street map with no
1751
+ // navigation aids covering an actual nautical chart underneath it.
1752
+ // OpenSeaMap's navigation-aids overlay (buoys, lights, marks) is always
1753
+ // offered too, on top of whichever base is active, since a registered
1754
+ // chart isn't guaranteed to already include them the way Kartverket's
1755
+ // does.
1509
1756
  async function addChartBaseLayers(map) {
1510
1757
  let tileCharts = [];
1511
1758
  try {
@@ -1514,6 +1761,8 @@
1514
1761
  } catch (e) {
1515
1762
  tileCharts = [];
1516
1763
  }
1764
+ const baseLayers = {};
1765
+ let defaultKey = null;
1517
1766
  if (tileCharts.length) {
1518
1767
  tileCharts.forEach((c) => {
1519
1768
  const opts = { maxZoom: c.maxzoom || 19, minZoom: c.minzoom || 0, attribution: c.name || '' };
@@ -1523,17 +1772,31 @@
1523
1772
  [c.bounds[3], c.bounds[2]]
1524
1773
  ];
1525
1774
  }
1526
- L.tileLayer(c.url, opts).addTo(map);
1775
+ const key = c.name || c.id;
1776
+ baseLayers[key] = L.tileLayer(c.url, opts);
1777
+ // Eniro's plain map (not "Eniro sjø", and not Kartverket/Hitta,
1778
+ // which both render very dark) is the easiest one to actually
1779
+ // read at a glance — preferred as the default when it's
1780
+ // registered, same as any other order these come back in.
1781
+ if ((c.id || '').toLowerCase() === 'eniro') defaultKey = key;
1527
1782
  });
1528
1783
  } else {
1529
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1784
+ baseLayers['OpenStreetMap'] = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1530
1785
  maxZoom: 19,
1531
1786
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
1532
- }).addTo(map);
1533
- L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
1787
+ });
1788
+ }
1789
+ baseLayers[defaultKey || Object.keys(baseLayers)[0]].addTo(map);
1790
+
1791
+ const overlays = {
1792
+ 'Navigation aids (OpenSeaMap)': L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
1534
1793
  maxZoom: 18,
1535
1794
  attribution: '&copy; <a href="https://www.openseamap.org">OpenSeaMap</a>'
1536
- }).addTo(map);
1795
+ }).addTo(map)
1796
+ };
1797
+
1798
+ if (Object.keys(baseLayers).length > 1 || Object.keys(overlays).length) {
1799
+ L.control.layers(baseLayers, overlays, { collapsed: true }).addTo(map);
1537
1800
  }
1538
1801
  }
1539
1802
 
@@ -2271,9 +2534,21 @@
2271
2534
  row.vetHandicapVersion = handicapVersion;
2272
2535
  }
2273
2536
  syncVetSelectValue(row, b.tcf);
2537
+ // A boat with no start time of its own already starts with the
2538
+ // fleet — effectiveStartTime (server-side) falls back to the race's
2539
+ // own start time, and elapsed/corrected already reflect that. The
2540
+ // input showing blank instead of that time made it look like the
2541
+ // boat hadn't started at all even while it was actively being
2542
+ // timed; showing the inherited value (dimmed, to mark it as
2543
+ // inherited rather than a boat-specific override) fixes that
2544
+ // without changing what's actually stored — only "Now" or typing a
2545
+ // value turns it into a real per-boat override (e.g. a pursuit
2546
+ // start), same as before.
2547
+ const effectiveStart = b.startTime != null ? b.startTime : raceState.startTime;
2274
2548
  if (document.activeElement !== row.startTimeInput) {
2275
- row.startTimeInput.value = raceState.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
2549
+ row.startTimeInput.value = raceState.multiDay ? tsToDateTimeInputValue(effectiveStart) : tsToTimeInputValue(effectiveStart);
2276
2550
  }
2551
+ row.startTimeInput.classList.toggle('inherited-value', b.startTime == null && raceState.startTime != null);
2277
2552
  const canStart = !!raceState.startTime;
2278
2553
  row.startTimeInput.disabled = !canStart;
2279
2554
  row.startNowBtn.disabled = !canStart;
@@ -2468,6 +2743,10 @@
2468
2743
  addMarkBtn.addEventListener('click', () => {
2469
2744
  markRefs.push(buildMarkRow(null));
2470
2745
  renderMarkRows();
2746
+ updateMarkPickGating();
2747
+ });
2748
+ document.addEventListener('keydown', (e) => {
2749
+ if (e.key === 'Escape' && mapPickTarget) cancelMapPick();
2471
2750
  });
2472
2751
  saveCourseBtn.addEventListener('click', saveCourse);
2473
2752
 
package/public/style.css CHANGED
@@ -120,6 +120,13 @@ button.confirming {
120
120
  font-variant-numeric: tabular-nums;
121
121
  }
122
122
 
123
+ /* Shows the race's own start time when this boat has no override of its
124
+ own — dimmed to mark it as inherited, not a boat-specific start. */
125
+ .start-time-input.inherited-value {
126
+ color: var(--muted);
127
+ font-style: italic;
128
+ }
129
+
123
130
  .controls {
124
131
  margin-top: 0.75rem;
125
132
  display: flex;
@@ -300,6 +307,15 @@ main {
300
307
  align-items: center;
301
308
  }
302
309
 
310
+ .course-point-row .course-name-autocomplete .suggestions {
311
+ /* The name field itself is only 7rem wide to fit inline in the row, but
312
+ the dropdown it opens shouldn't be squeezed to match — that made boat
313
+ and waypoint names unreadable. right:auto lets it grow past the
314
+ parent's edge instead of being pinned to it. */
315
+ right: auto;
316
+ min-width: 16rem;
317
+ }
318
+
303
319
  .course-point-row .course-name-autocomplete {
304
320
  position: relative;
305
321
  width: 7rem;
@@ -330,6 +346,16 @@ main {
330
346
  font-size: 0.75rem;
331
347
  }
332
348
 
349
+ .course-point-row button.pick-on-map-btn.active {
350
+ background: var(--accent);
351
+ color: #04202e;
352
+ border-color: var(--accent);
353
+ }
354
+
355
+ .chart-map.picking {
356
+ cursor: crosshair;
357
+ }
358
+
333
359
  .course-actions {
334
360
  margin-top: 0.75rem;
335
361
  display: flex;
@@ -441,6 +467,20 @@ main {
441
467
  opacity: 0.85;
442
468
  }
443
469
 
470
+ .chart-map .leaflet-control-layers {
471
+ background: var(--panel);
472
+ color: var(--text);
473
+ border: 1px solid var(--border);
474
+ }
475
+
476
+ .chart-map .leaflet-control-layers-toggle {
477
+ filter: invert(1);
478
+ }
479
+
480
+ .chart-map .leaflet-control-layers label {
481
+ color: var(--text);
482
+ }
483
+
444
484
  .replay-controls {
445
485
  margin-top: 0.5rem;
446
486
  display: flex;
@@ -591,6 +631,13 @@ main {
591
631
  font-weight: 700;
592
632
  }
593
633
 
634
+ .suggestions .suggestion-item .suggestion-source {
635
+ float: right;
636
+ font-size: 0.7rem;
637
+ color: var(--muted);
638
+ font-style: italic;
639
+ }
640
+
594
641
  .suggestions .suggestion-item:hover,
595
642
  .suggestions .suggestion-item.active {
596
643
  background: rgba(56, 189, 248, 0.15);