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 +39 -14
- package/index.js +8 -1
- package/package.json +1 -1
- package/public/app.js +313 -34
- package/public/style.css +47 -0
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
|
|
23
|
-
|
|
24
|
-
|
|
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** —
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
111
|
-
OpenStreetMap
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
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
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
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
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
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
|
-
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
1532
|
-
})
|
|
1533
|
-
|
|
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: '© <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(
|
|
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);
|