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 +27 -11
- package/index.js +8 -1
- package/package.json +1 -1
- package/public/app.js +191 -27
- package/public/style.css +28 -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
|
|
@@ -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)
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
120
|
-
OpenStreetMap
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1608
|
-
|
|
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
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
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
|
-
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
1643
|
-
})
|
|
1644
|
-
|
|
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: '© <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(
|
|
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);
|