signalk-race-control 0.9.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
@@ -101,11 +103,17 @@ and a live projected finishing order — during and after the race.
101
103
  pre-start approach is moot by then.
102
104
  - **Course & chart** — set the start line, an ordered list of rounding marks, and the
103
105
  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
+ 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
106
111
  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
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
109
117
  position — not while still typing lat/lon by hand) also suggests a name for it from
110
118
  OpenStreetMap, when there's a real charted place right there and the name field is
111
119
  still empty; typing or picking a waypoint always wins over the suggestion. A mark's
@@ -116,10 +124,18 @@ and a live projected finishing order — during and after the race.
116
124
  course) shows on the chart too, as a small dim pin with its name, so it's there to
117
125
  reference — or type into a mark's name field — even before it's used.
118
126
  The chart itself is a real background map, like freeboard-sk's: it uses whatever
119
- tile-based chart resource this SignalK server has registered, or falls back to public
120
- OpenStreetMap + OpenSeaMap tiles when none is configured (same fallback freeboard-sk
121
- itself uses). Overlaid on it is each AIS-tracked boat's recorded track for the current
122
- 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
123
139
  through it, or leave it on **Live**. Click **Play** to have it step through on its own
124
140
  instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
125
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.9.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
@@ -1072,15 +1072,24 @@
1072
1072
  // instances (one per course point row) can coexist without stepping on
1073
1073
  // each other the way sharing the boat autocomplete's module-level state
1074
1074
  // would.
1075
- 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) {
1076
1082
  let items = [];
1077
1083
  let activeIndex = -1;
1084
+ let extraRequestId = 0;
1085
+ let extraDebounceTimer = null;
1078
1086
 
1079
1087
  function hide() {
1080
1088
  dropdown.hidden = true;
1081
1089
  dropdown.innerHTML = '';
1082
1090
  items = [];
1083
1091
  activeIndex = -1;
1092
+ clearTimeout(extraDebounceTimer);
1084
1093
  }
1085
1094
  function renderActive() {
1086
1095
  Array.from(dropdown.children).forEach((el, i) => el.classList.toggle('active', i === activeIndex));
@@ -1089,15 +1098,7 @@
1089
1098
  onSelect(item);
1090
1099
  hide();
1091
1100
  }
1092
- function showFor(query) {
1093
- const q = query.trim().toLowerCase();
1094
- if (!q) {
1095
- hide();
1096
- return;
1097
- }
1098
- const matches = getPool()
1099
- .filter((it) => it.name.toLowerCase().includes(q))
1100
- .slice(0, 20);
1101
+ function renderMatches(matches, q) {
1101
1102
  items = matches;
1102
1103
  activeIndex = -1;
1103
1104
  if (!matches.length) {
@@ -1119,6 +1120,7 @@
1119
1120
  '</mark>' +
1120
1121
  escapeHtml(item.name.slice(idx + q.length));
1121
1122
  }
1123
+ if (item.source === 'osm') div.innerHTML += '<span class="suggestion-source">OpenSeaMap</span>';
1122
1124
  // mousedown (not click) fires before the input's blur, same reason
1123
1125
  // as the boat-name suggestions above.
1124
1126
  div.addEventListener('mousedown', (e) => {
@@ -1129,6 +1131,30 @@
1129
1131
  });
1130
1132
  dropdown.hidden = false;
1131
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
+ }
1132
1158
 
1133
1159
  input.addEventListener('input', () => showFor(input.value));
1134
1160
  input.addEventListener('focus', () => {
@@ -1153,6 +1179,93 @@
1153
1179
  });
1154
1180
  }
1155
1181
 
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
+
1156
1269
  // One name+lat+lon+position-button row, shared by start line, finish
1157
1270
  // line, and mark entry. The name field autocompletes against existing
1158
1271
  // SignalK waypoints (e.g. ones already placed on a chart plotter) —
@@ -1230,7 +1343,7 @@
1230
1343
  if (mapPickTarget && mapPickTarget.btn === posBtn) {
1231
1344
  cancelMapPick();
1232
1345
  } else {
1233
- armMapPick(latInput, lonInput, posBtn, nameInput.placeholder || 'this point', handlePositionSet);
1346
+ armMapPick(latInput, lonInput, nameInput, posBtn, nameInput.placeholder || 'this point', handlePositionSet);
1234
1347
  }
1235
1348
  return;
1236
1349
  }
@@ -1253,7 +1366,8 @@
1253
1366
  latInput.value = wp.lat;
1254
1367
  lonInput.value = wp.lon;
1255
1368
  notifyPositionChanged();
1256
- }
1369
+ },
1370
+ searchOsmPlaces
1257
1371
  );
1258
1372
 
1259
1373
  updatePosBtnLabel();
@@ -1284,9 +1398,9 @@
1284
1398
  // Arms "pick on map" mode: the next click on the chart fills this row's
1285
1399
  // lat/lon from wherever was clicked. Clicking the same button again, a
1286
1400
  // different row's pick button, or Escape all cancel it.
1287
- function armMapPick(latInput, lonInput, btn, label, onPositionChanged) {
1401
+ function armMapPick(latInput, lonInput, nameInput, btn, label, onPositionChanged) {
1288
1402
  cancelMapPick();
1289
- mapPickTarget = { latInput, lonInput, btn, onPositionChanged };
1403
+ mapPickTarget = { latInput, lonInput, nameInput, btn, onPositionChanged };
1290
1404
  btn.classList.add('active');
1291
1405
  courseChart.classList.add('picking');
1292
1406
  setCourseStatus(`Click the map below to place "${label}".`);
@@ -1601,22 +1715,44 @@
1601
1715
  chartMap = L.map(courseChart, { attributionControl: true }).setView([0, 0], 2);
1602
1716
  chartLayerGroup = L.layerGroup().addTo(chartMap);
1603
1717
  addChartBaseLayers(chartMap);
1604
- chartMap.on('click', (e) => {
1718
+ chartMap.on('click', async (e) => {
1605
1719
  if (!mapPickTarget) return;
1606
1720
  const target = mapPickTarget;
1607
- target.latInput.value = e.latlng.lat.toFixed(6);
1608
- target.lonInput.value = e.latlng.lng.toFixed(6);
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);
1609
1725
  cancelMapPick();
1610
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();
1611
1738
  });
1612
1739
  return chartMap;
1613
1740
  }
1614
1741
 
1615
- // Prefers whatever tile-based chart resource this SignalK server has
1742
+ // Prefers whatever tile-based chart resource(s) this SignalK server has
1616
1743
  // registered (e.g. a locally cached raster chart) — same as freeboard-sk
1617
- // itself would use. Falls back to public OpenStreetMap + OpenSeaMap
1618
- // tiles when none is configured, which is also freeboard-sk's own
1619
- // 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.
1620
1756
  async function addChartBaseLayers(map) {
1621
1757
  let tileCharts = [];
1622
1758
  try {
@@ -1625,6 +1761,8 @@
1625
1761
  } catch (e) {
1626
1762
  tileCharts = [];
1627
1763
  }
1764
+ const baseLayers = {};
1765
+ let defaultKey = null;
1628
1766
  if (tileCharts.length) {
1629
1767
  tileCharts.forEach((c) => {
1630
1768
  const opts = { maxZoom: c.maxzoom || 19, minZoom: c.minzoom || 0, attribution: c.name || '' };
@@ -1634,17 +1772,31 @@
1634
1772
  [c.bounds[3], c.bounds[2]]
1635
1773
  ];
1636
1774
  }
1637
- 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;
1638
1782
  });
1639
1783
  } else {
1640
- 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', {
1641
1785
  maxZoom: 19,
1642
1786
  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
1643
- }).addTo(map);
1644
- 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', {
1645
1793
  maxZoom: 18,
1646
1794
  attribution: '&copy; <a href="https://www.openseamap.org">OpenSeaMap</a>'
1647
- }).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);
1648
1800
  }
1649
1801
  }
1650
1802
 
@@ -2382,9 +2534,21 @@
2382
2534
  row.vetHandicapVersion = handicapVersion;
2383
2535
  }
2384
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;
2385
2548
  if (document.activeElement !== row.startTimeInput) {
2386
- row.startTimeInput.value = raceState.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
2549
+ row.startTimeInput.value = raceState.multiDay ? tsToDateTimeInputValue(effectiveStart) : tsToTimeInputValue(effectiveStart);
2387
2550
  }
2551
+ row.startTimeInput.classList.toggle('inherited-value', b.startTime == null && raceState.startTime != null);
2388
2552
  const canStart = !!raceState.startTime;
2389
2553
  row.startTimeInput.disabled = !canStart;
2390
2554
  row.startNowBtn.disabled = !canStart;
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;
@@ -460,6 +467,20 @@ main {
460
467
  opacity: 0.85;
461
468
  }
462
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
+
463
484
  .replay-controls {
464
485
  margin-top: 0.5rem;
465
486
  display: flex;
@@ -610,6 +631,13 @@ main {
610
631
  font-weight: 700;
611
632
  }
612
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
+
613
641
  .suggestions .suggestion-item:hover,
614
642
  .suggestions .suggestion-item.active {
615
643
  background: rgba(56, 189, 248, 0.15);