signalk-race-control 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -7
- package/index.js +133 -12
- package/package.json +1 -1
- package/public/app.js +273 -79
- package/public/index.html +4 -1
- package/public/style.css +53 -2
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
package/README.md
CHANGED
|
@@ -99,12 +99,27 @@ and a live projected finishing order — during and after the race.
|
|
|
99
99
|
slow down or take a longer approach); negative means you're behind schedule to make
|
|
100
100
|
the line before the gun. Disappears once the race actually starts, since the
|
|
101
101
|
pre-start approach is moot by then.
|
|
102
|
-
- **Course & chart** —
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
- **Course & chart** — set the start line, an ordered list of rounding marks, and the
|
|
103
|
+
finish line by whichever's easiest: type lat/lon directly, type a name to autocomplete
|
|
104
|
+
against existing SignalK waypoints (e.g. ones already placed from a chart plotter) and
|
|
105
|
+
pick one to fill in its position, or use the position button — it reads **Pick on
|
|
106
|
+
map** while that point is still unset (arm it, then click the spot on the chart
|
|
107
|
+
below), and switches to **Use my position** once it has one, to re-centre it on
|
|
108
|
+
wherever you are right now instead. Setting a position this way (map click or self
|
|
109
|
+
position — not while still typing lat/lon by hand) also suggests a name for it from
|
|
110
|
+
OpenStreetMap, when there's a real charted place right there and the name field is
|
|
111
|
+
still empty; typing or picking a waypoint always wins over the suggestion. A mark's
|
|
112
|
+
own **Pick on map** only turns on once the start line has a position — before that the
|
|
113
|
+
chart has nothing to anchor its zoom to and is still showing the whole world, not much
|
|
114
|
+
use to click into (typed lat/lon, autocomplete, and "Use my position" all stay
|
|
115
|
+
available regardless). Every other known waypoint (not part of this race's own
|
|
116
|
+
course) shows on the chart too, as a small dim pin with its name, so it's there to
|
|
117
|
+
reference — or type into a mark's name field — even before it's used.
|
|
118
|
+
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
|
|
108
123
|
through it, or leave it on **Live**. Click **Play** to have it step through on its own
|
|
109
124
|
instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
|
|
110
125
|
from the earliest recorded position when played from Live (there's nothing to play
|
|
@@ -118,7 +133,10 @@ and a live projected finishing order — during and after the race.
|
|
|
118
133
|
back to just the last real position (no ring) instead of interpolating across a gap
|
|
119
134
|
longer than 5 minutes — AIS dropping out, or a mark rounding recorded far from any
|
|
120
135
|
real fix — since a straight line across a gap that long would be a guess, not an
|
|
121
|
-
estimate.
|
|
136
|
+
estimate. A notable pickup in speed between two consecutive samples (a gust, better
|
|
137
|
+
trim, planing) is marked right on the track as a ring in that boat's own colour, sized
|
|
138
|
+
a bit larger for a bigger jump — hover it for the exact speed reached. The course is
|
|
139
|
+
also published as SignalK
|
|
122
140
|
waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
|
|
123
141
|
standard resources API — both directions (reading existing waypoints for the
|
|
124
142
|
autocomplete, and publishing the saved course) only do anything if your server has a
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const ExcelJS = require('exceljs');
|
|
4
5
|
|
|
5
6
|
const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
|
|
@@ -476,8 +477,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
476
477
|
.status.error { color: var(--bad); }
|
|
477
478
|
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); margin: 0 auto; }
|
|
478
479
|
.add-boat-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
479
|
-
.add-boat-row input[type='text'] {
|
|
480
|
+
.add-boat-row input[type='text'] { width: 100%; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
480
481
|
.add-boat-row input[type='number'] { width: 6rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
482
|
+
.autocomplete { position: relative; flex: 1; min-width: 12rem; max-width: 20rem; }
|
|
483
|
+
.suggestions { position: absolute; top: calc(100% + 2px); left: 0; right: 0; z-index: 10; max-height: 16rem; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
|
484
|
+
.suggestions .suggestion-item { padding: 0.45rem 0.6rem; font-size: 0.85rem; cursor: pointer; color: var(--text); }
|
|
485
|
+
.suggestions .suggestion-item mark { background: none; color: var(--accent); font-weight: 700; }
|
|
486
|
+
.suggestions .suggestion-item:hover, .suggestions .suggestion-item.active { background: rgba(56, 189, 248, 0.15); }
|
|
481
487
|
.table-scroll { overflow-x: auto; }
|
|
482
488
|
table { width: 100%; min-width: 44rem; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
|
483
489
|
thead th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
@@ -539,8 +545,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
539
545
|
</header>
|
|
540
546
|
<main>
|
|
541
547
|
<div class="add-boat-row">
|
|
542
|
-
<
|
|
543
|
-
|
|
548
|
+
<div class="autocomplete">
|
|
549
|
+
<input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" />
|
|
550
|
+
<div id="addBoatSuggestions" class="suggestions" hidden></div>
|
|
551
|
+
</div>
|
|
544
552
|
<input type="number" id="addBoatTcf" step="0.001" min="0.01" title="TCF" />
|
|
545
553
|
<button id="addBoatBtn">Add Boat</button>
|
|
546
554
|
</div>
|
|
@@ -749,18 +757,62 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
749
757
|
return boats;
|
|
750
758
|
}
|
|
751
759
|
|
|
760
|
+
var nameSuggestionPool = [];
|
|
761
|
+
var suggestionItems = [];
|
|
762
|
+
var suggestionActiveIndex = -1;
|
|
752
763
|
function rebuildAddBoatSuggestions() {
|
|
753
|
-
addBoatSuggestions.innerHTML = '';
|
|
754
764
|
var seen = {};
|
|
765
|
+
nameSuggestionPool = [];
|
|
755
766
|
race.handicapBoats.concat(race.ktkBoats).forEach(function (b) {
|
|
756
767
|
var key = b.name.toLowerCase();
|
|
757
768
|
if (seen[key]) return;
|
|
758
769
|
seen[key] = true;
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
770
|
+
nameSuggestionPool.push({ name: b.name });
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function escapeHtml(s) {
|
|
774
|
+
return s.replace(/[&<>"']/g, function (c) {
|
|
775
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function hideSuggestions() {
|
|
779
|
+
addBoatSuggestions.hidden = true;
|
|
780
|
+
addBoatSuggestions.innerHTML = '';
|
|
781
|
+
suggestionItems = [];
|
|
782
|
+
suggestionActiveIndex = -1;
|
|
783
|
+
}
|
|
784
|
+
function renderSuggestionActive() {
|
|
785
|
+
Array.prototype.forEach.call(addBoatSuggestions.children, function (el, i) {
|
|
786
|
+
el.classList.toggle('active', i === suggestionActiveIndex);
|
|
762
787
|
});
|
|
763
788
|
}
|
|
789
|
+
function selectSuggestion(item) {
|
|
790
|
+
addBoatName.value = item.name;
|
|
791
|
+
hideSuggestions();
|
|
792
|
+
addBoatName.focus();
|
|
793
|
+
}
|
|
794
|
+
function showSuggestionsFor(query) {
|
|
795
|
+
var q = query.trim().toLowerCase();
|
|
796
|
+
if (!q) { hideSuggestions(); return; }
|
|
797
|
+
var matches = nameSuggestionPool.filter(function (s) { return s.name.toLowerCase().indexOf(q) !== -1; }).slice(0, 20);
|
|
798
|
+
suggestionItems = matches;
|
|
799
|
+
suggestionActiveIndex = -1;
|
|
800
|
+
if (!matches.length) { hideSuggestions(); return; }
|
|
801
|
+
addBoatSuggestions.innerHTML = '';
|
|
802
|
+
matches.forEach(function (item) {
|
|
803
|
+
var div = document.createElement('div');
|
|
804
|
+
div.className = 'suggestion-item';
|
|
805
|
+
var idx = item.name.toLowerCase().indexOf(q);
|
|
806
|
+
if (idx === -1) {
|
|
807
|
+
div.textContent = item.name;
|
|
808
|
+
} else {
|
|
809
|
+
div.innerHTML = escapeHtml(item.name.slice(0, idx)) + '<mark>' + escapeHtml(item.name.slice(idx, idx + q.length)) + '</mark>' + escapeHtml(item.name.slice(idx + q.length));
|
|
810
|
+
}
|
|
811
|
+
div.addEventListener('mousedown', function (e) { e.preventDefault(); selectSuggestion(item); });
|
|
812
|
+
addBoatSuggestions.appendChild(div);
|
|
813
|
+
});
|
|
814
|
+
addBoatSuggestions.hidden = false;
|
|
815
|
+
}
|
|
764
816
|
|
|
765
817
|
function loadHandicapRegister(force) {
|
|
766
818
|
if (!race.vetEnabled) return;
|
|
@@ -1374,9 +1426,32 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1374
1426
|
setStatus('');
|
|
1375
1427
|
render();
|
|
1376
1428
|
}
|
|
1377
|
-
addBoatBtn.addEventListener('click', addBoat);
|
|
1429
|
+
addBoatBtn.addEventListener('click', function () { hideSuggestions(); addBoat(); });
|
|
1430
|
+
addBoatName.addEventListener('input', function () { showSuggestionsFor(addBoatName.value); });
|
|
1431
|
+
addBoatName.addEventListener('focus', function () {
|
|
1432
|
+
if (addBoatName.value.trim()) showSuggestionsFor(addBoatName.value);
|
|
1433
|
+
});
|
|
1434
|
+
addBoatName.addEventListener('blur', function () { hideSuggestions(); });
|
|
1378
1435
|
addBoatName.addEventListener('keydown', function (e) {
|
|
1379
|
-
if (e.key === '
|
|
1436
|
+
if (e.key === 'ArrowDown' && suggestionItems.length) {
|
|
1437
|
+
e.preventDefault();
|
|
1438
|
+
suggestionActiveIndex = (suggestionActiveIndex + 1) % suggestionItems.length;
|
|
1439
|
+
renderSuggestionActive();
|
|
1440
|
+
} else if (e.key === 'ArrowUp' && suggestionItems.length) {
|
|
1441
|
+
e.preventDefault();
|
|
1442
|
+
suggestionActiveIndex = (suggestionActiveIndex - 1 + suggestionItems.length) % suggestionItems.length;
|
|
1443
|
+
renderSuggestionActive();
|
|
1444
|
+
} else if (e.key === 'Enter') {
|
|
1445
|
+
e.preventDefault();
|
|
1446
|
+
if (suggestionActiveIndex >= 0 && suggestionItems[suggestionActiveIndex]) {
|
|
1447
|
+
selectSuggestion(suggestionItems[suggestionActiveIndex]);
|
|
1448
|
+
} else {
|
|
1449
|
+
hideSuggestions();
|
|
1450
|
+
addBoat();
|
|
1451
|
+
}
|
|
1452
|
+
} else if (e.key === 'Escape') {
|
|
1453
|
+
hideSuggestions();
|
|
1454
|
+
}
|
|
1380
1455
|
});
|
|
1381
1456
|
|
|
1382
1457
|
downloadCsvBtn.addEventListener('click', function () {
|
|
@@ -1766,6 +1841,26 @@ module.exports = function (app) {
|
|
|
1766
1841
|
});
|
|
1767
1842
|
}
|
|
1768
1843
|
|
|
1844
|
+
// SignalK resource ids must be UUIDs (the resources-provider rejects
|
|
1845
|
+
// anything else) — this derives one deterministically from a stable seed
|
|
1846
|
+
// string, so re-publishing the same course/mark updates the same
|
|
1847
|
+
// resource instead of leaving a new one behind every time it's saved.
|
|
1848
|
+
function deterministicUuid(seed) {
|
|
1849
|
+
const hash = crypto.createHash('sha1').update(seed).digest('hex');
|
|
1850
|
+
return (
|
|
1851
|
+
hash.slice(0, 8) +
|
|
1852
|
+
'-' +
|
|
1853
|
+
hash.slice(8, 12) +
|
|
1854
|
+
'-4' +
|
|
1855
|
+
hash.slice(13, 16) +
|
|
1856
|
+
'-' +
|
|
1857
|
+
((parseInt(hash[16], 16) & 0x3) | 0x8).toString(16) +
|
|
1858
|
+
hash.slice(17, 20) +
|
|
1859
|
+
'-' +
|
|
1860
|
+
hash.slice(20, 32)
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1769
1864
|
function waypointResource(name, pt) {
|
|
1770
1865
|
return {
|
|
1771
1866
|
name,
|
|
@@ -1797,7 +1892,7 @@ module.exports = function (app) {
|
|
|
1797
1892
|
if (!app.resourcesApi || typeof app.resourcesApi.setResource !== 'function') return;
|
|
1798
1893
|
try {
|
|
1799
1894
|
const points = [];
|
|
1800
|
-
const put = (
|
|
1895
|
+
const put = (seed, name, pt) => app.resourcesApi.setResource('waypoints', deterministicUuid(seed), waypointResource(name, pt));
|
|
1801
1896
|
if (race.course.startLine) {
|
|
1802
1897
|
await put(`race-${race.id}-start-pin`, `${race.name} — Start (pin)`, race.course.startLine[0]);
|
|
1803
1898
|
await put(`race-${race.id}-start-committee`, `${race.name} — Start (committee)`, race.course.startLine[1]);
|
|
@@ -1813,7 +1908,7 @@ module.exports = function (app) {
|
|
|
1813
1908
|
points.push(race.course.finishLine[0], race.course.finishLine[1]);
|
|
1814
1909
|
}
|
|
1815
1910
|
if (points.length >= 2) {
|
|
1816
|
-
await app.resourcesApi.setResource('routes', `race-${race.id}-course
|
|
1911
|
+
await app.resourcesApi.setResource('routes', deterministicUuid(`race-${race.id}-course`), routeResource(race.name, points));
|
|
1817
1912
|
}
|
|
1818
1913
|
} catch (e) {
|
|
1819
1914
|
app.debug('race-control: could not publish course to SignalK resources: ' + e.message);
|
|
@@ -1832,7 +1927,7 @@ module.exports = function (app) {
|
|
|
1832
1927
|
const live = getLivePosition(boat.mmsi);
|
|
1833
1928
|
if (!live) return;
|
|
1834
1929
|
if (!boat.track) boat.track = [];
|
|
1835
|
-
boat.track.push({ t: Date.now(), lat: live.lat, lon: live.lon });
|
|
1930
|
+
boat.track.push({ t: Date.now(), lat: live.lat, lon: live.lon, sog: live.sogMs });
|
|
1836
1931
|
if (boat.track.length > 2000) boat.track.shift();
|
|
1837
1932
|
changed = true;
|
|
1838
1933
|
});
|
|
@@ -2696,6 +2791,32 @@ module.exports = function (app) {
|
|
|
2696
2791
|
}
|
|
2697
2792
|
});
|
|
2698
2793
|
|
|
2794
|
+
// Background chart tiles for the webapp's course/replay map — same
|
|
2795
|
+
// "best-effort, never fails the caller" spirit as /waypoints. Only
|
|
2796
|
+
// tilelayer-type chart resources are usable here (a plain XYZ tile URL
|
|
2797
|
+
// template); WMS/WMTS/mapstyleJSON/S-57 sources need more than a tile
|
|
2798
|
+
// layer to render and are left for a future version. The webapp falls
|
|
2799
|
+
// back to public OpenStreetMap/OpenSeaMap tiles when this returns none,
|
|
2800
|
+
// same as freeboard-sk itself does with no chart provider installed.
|
|
2801
|
+
router.get('/charts', async (req, res) => {
|
|
2802
|
+
if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') {
|
|
2803
|
+
return res.json({ charts: [] });
|
|
2804
|
+
}
|
|
2805
|
+
try {
|
|
2806
|
+
const data = await app.resourcesApi.listResources('charts', {});
|
|
2807
|
+
const charts = Object.keys(data || {})
|
|
2808
|
+
.map((id) => {
|
|
2809
|
+
const c = data[id] || {};
|
|
2810
|
+
if (c.type !== 'tilelayer' || !c.url) return null;
|
|
2811
|
+
return { id, name: c.name || id, url: c.url, minzoom: c.minzoom, maxzoom: c.maxzoom, bounds: c.bounds };
|
|
2812
|
+
})
|
|
2813
|
+
.filter(Boolean);
|
|
2814
|
+
res.json({ charts });
|
|
2815
|
+
} catch (e) {
|
|
2816
|
+
res.json({ charts: [] });
|
|
2817
|
+
}
|
|
2818
|
+
});
|
|
2819
|
+
|
|
2699
2820
|
router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
|
|
2700
2821
|
const race = getRace(req.params.id);
|
|
2701
2822
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Joachim Bakke <github@heiamoss.com>",
|