signalk-race-control 0.7.0 → 0.8.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 +13 -4
- package/index.js +133 -12
- package/package.json +1 -1
- package/public/app.js +145 -66
- package/public/index.html +4 -1
- package/public/style.css +34 -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
|
@@ -102,9 +102,15 @@ and a live projected finishing order — during and after the race.
|
|
|
102
102
|
- **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
|
|
103
103
|
marks, and the finish line (or click **Use my position** if you're sitting at that
|
|
104
104
|
spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
|
|
105
|
-
already placed from a chart plotter — and pick one to fill in its position
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
already placed from a chart plotter — and pick one to fill in its position; typing
|
|
106
|
+
lat/lon directly works just as well). Every other known waypoint (not part of this
|
|
107
|
+
race's own course) shows on the chart too, as a small dim pin with its name, so it's
|
|
108
|
+
there to reference — or type into a mark's name field — even before it's used.
|
|
109
|
+
The chart itself is a real background map, like freeboard-sk's: it uses whatever
|
|
110
|
+
tile-based chart resource this SignalK server has registered, or falls back to public
|
|
111
|
+
OpenStreetMap + OpenSeaMap tiles when none is configured (same fallback freeboard-sk
|
|
112
|
+
itself uses). Overlaid on it is each AIS-tracked boat's recorded track for the current
|
|
113
|
+
race — drag the **replay** slider to step back
|
|
108
114
|
through it, or leave it on **Live**. Click **Play** to have it step through on its own
|
|
109
115
|
instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
|
|
110
116
|
from the earliest recorded position when played from Live (there's nothing to play
|
|
@@ -118,7 +124,10 @@ and a live projected finishing order — during and after the race.
|
|
|
118
124
|
back to just the last real position (no ring) instead of interpolating across a gap
|
|
119
125
|
longer than 5 minutes — AIS dropping out, or a mark rounding recorded far from any
|
|
120
126
|
real fix — since a straight line across a gap that long would be a guess, not an
|
|
121
|
-
estimate.
|
|
127
|
+
estimate. A notable pickup in speed between two consecutive samples (a gust, better
|
|
128
|
+
trim, planing) is marked right on the track as a ring in that boat's own colour, sized
|
|
129
|
+
a bit larger for a bigger jump — hover it for the exact speed reached. The course is
|
|
130
|
+
also published as SignalK
|
|
122
131
|
waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
|
|
123
132
|
standard resources API — both directions (reading existing waypoints for the
|
|
124
133
|
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.8.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
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
let replayPlayTimer = null;
|
|
32
32
|
let replayPlayLastTick = null;
|
|
33
33
|
let waypoints = []; // [{id, name, lat, lon}] from SignalK resources, for the course editor's "Pick a waypoint" dropdown
|
|
34
|
+
let chartMap = null; // Leaflet map, created lazily once the chart section is first expanded
|
|
35
|
+
let chartLayerGroup = null; // holds everything renderChart() redraws, cleared and rebuilt each call
|
|
36
|
+
let chartActiveRaceId; // which race's bounds were last auto-fit, so switching races re-fits once
|
|
37
|
+
let chartFitDone = false;
|
|
34
38
|
|
|
35
39
|
const raceSelect = document.getElementById('raceSelect');
|
|
36
40
|
const newRaceBtn = document.getElementById('newRaceBtn');
|
|
@@ -83,6 +87,7 @@
|
|
|
83
87
|
const saveCourseBtn = document.getElementById('saveCourseBtn');
|
|
84
88
|
const courseStatusText = document.getElementById('courseStatusText');
|
|
85
89
|
const courseChart = document.getElementById('courseChart');
|
|
90
|
+
const chartEmptyMsg = document.getElementById('chartEmptyMsg');
|
|
86
91
|
const replayControls = document.getElementById('replayControls');
|
|
87
92
|
const replaySlider = document.getElementById('replaySlider');
|
|
88
93
|
const replayTimeLabel = document.getElementById('replayTimeLabel');
|
|
@@ -1477,79 +1482,123 @@
|
|
|
1477
1482
|
|
|
1478
1483
|
const CHART_PALETTE = ['#38bdf8', '#fbbf24', '#f472b6', '#a78bfa', '#34d399', '#fb923c', '#60a5fa', '#facc15'];
|
|
1479
1484
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
// the SVG's viewBox with padding. Re-derived on every render rather than
|
|
1483
|
-
// held fixed, since the bounding box grows as tracks come in.
|
|
1484
|
-
function buildProjection(points, width, height, padding) {
|
|
1485
|
-
const meanLat = points.reduce((s, p) => s + p.lat, 0) / points.length;
|
|
1486
|
-
const cosLat = Math.cos((meanLat * Math.PI) / 180) || 1;
|
|
1487
|
-
const xs = points.map((p) => p.lon * cosLat);
|
|
1488
|
-
const ys = points.map((p) => -p.lat);
|
|
1489
|
-
const minX = Math.min(...xs);
|
|
1490
|
-
const maxX = Math.max(...xs);
|
|
1491
|
-
const minY = Math.min(...ys);
|
|
1492
|
-
const maxY = Math.max(...ys);
|
|
1493
|
-
const spanX = Math.max(maxX - minX, 1e-9);
|
|
1494
|
-
const spanY = Math.max(maxY - minY, 1e-9);
|
|
1495
|
-
const scale = Math.min((width - 2 * padding) / spanX, (height - 2 * padding) / spanY);
|
|
1496
|
-
const offX = padding + (width - 2 * padding - spanX * scale) / 2;
|
|
1497
|
-
const offY = padding + (height - 2 * padding - spanY * scale) / 2;
|
|
1498
|
-
return (p) => ({ x: offX + (p.lon * cosLat - minX) * scale, y: offY + (-p.lat - minY) * scale });
|
|
1485
|
+
function ll(pt) {
|
|
1486
|
+
return [pt.lat, pt.lon];
|
|
1499
1487
|
}
|
|
1500
1488
|
|
|
1501
|
-
function
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1489
|
+
function midpoint(a, b) {
|
|
1490
|
+
return { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Created lazily, the first time the chart actually has somewhere to
|
|
1494
|
+
// render into — Leaflet can't size a map inside a still-hidden container,
|
|
1495
|
+
// and the course section starts collapsed.
|
|
1496
|
+
function ensureChartMap() {
|
|
1497
|
+
if (chartMap || typeof L === 'undefined') return chartMap;
|
|
1498
|
+
chartMap = L.map(courseChart, { attributionControl: true }).setView([0, 0], 2);
|
|
1499
|
+
chartLayerGroup = L.layerGroup().addTo(chartMap);
|
|
1500
|
+
addChartBaseLayers(chartMap);
|
|
1501
|
+
return chartMap;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Prefers whatever tile-based chart resource this SignalK server has
|
|
1505
|
+
// registered (e.g. a locally cached raster chart) — same as freeboard-sk
|
|
1506
|
+
// itself would use. Falls back to public OpenStreetMap + OpenSeaMap
|
|
1507
|
+
// tiles when none is configured, which is also freeboard-sk's own
|
|
1508
|
+
// fallback with no chart provider installed.
|
|
1509
|
+
async function addChartBaseLayers(map) {
|
|
1510
|
+
let tileCharts = [];
|
|
1511
|
+
try {
|
|
1512
|
+
const data = await fetchJSON(`${API}/charts`);
|
|
1513
|
+
tileCharts = data.charts || [];
|
|
1514
|
+
} catch (e) {
|
|
1515
|
+
tileCharts = [];
|
|
1516
|
+
}
|
|
1517
|
+
if (tileCharts.length) {
|
|
1518
|
+
tileCharts.forEach((c) => {
|
|
1519
|
+
const opts = { maxZoom: c.maxzoom || 19, minZoom: c.minzoom || 0, attribution: c.name || '' };
|
|
1520
|
+
if (Array.isArray(c.bounds) && c.bounds.length === 4) {
|
|
1521
|
+
opts.bounds = [
|
|
1522
|
+
[c.bounds[1], c.bounds[0]],
|
|
1523
|
+
[c.bounds[3], c.bounds[2]]
|
|
1524
|
+
];
|
|
1525
|
+
}
|
|
1526
|
+
L.tileLayer(c.url, opts).addTo(map);
|
|
1527
|
+
});
|
|
1528
|
+
} else {
|
|
1529
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
1530
|
+
maxZoom: 19,
|
|
1531
|
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
1532
|
+
}).addTo(map);
|
|
1533
|
+
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
|
1534
|
+
maxZoom: 18,
|
|
1535
|
+
attribution: '© <a href="https://www.openseamap.org">OpenSeaMap</a>'
|
|
1536
|
+
}).addTo(map);
|
|
1506
1537
|
}
|
|
1507
|
-
|
|
1508
|
-
const boatsWithTrack = Object.values(raceState.boats || {}).filter((b) => b.track && b.track.length);
|
|
1538
|
+
}
|
|
1509
1539
|
|
|
1510
|
-
|
|
1511
|
-
if (
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1540
|
+
function renderChart() {
|
|
1541
|
+
if (courseBody.hidden) return; // nothing to size a map into yet
|
|
1542
|
+
const map = ensureChartMap();
|
|
1543
|
+
if (!map) return; // Leaflet didn't load (offline, blocked) — rest of the app is unaffected
|
|
1544
|
+
chartLayerGroup.clearLayers();
|
|
1545
|
+
|
|
1546
|
+
if (chartActiveRaceId !== activeRaceId) {
|
|
1547
|
+
chartActiveRaceId = activeRaceId;
|
|
1548
|
+
chartFitDone = false;
|
|
1549
|
+
}
|
|
1515
1550
|
|
|
1516
|
-
if (
|
|
1517
|
-
|
|
1518
|
-
'<text x="400" y="250" text-anchor="middle" fill="var(--muted)" font-size="14">Add a course, then start the race, to see the chart here</text>';
|
|
1551
|
+
if (!raceState) {
|
|
1552
|
+
chartEmptyMsg.hidden = false;
|
|
1519
1553
|
replayControls.hidden = true;
|
|
1520
1554
|
return;
|
|
1521
1555
|
}
|
|
1522
1556
|
|
|
1523
|
-
const
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1557
|
+
const course = raceState.course || { startLine: null, marks: [], finishLine: null };
|
|
1558
|
+
const boatsWithTrack = Object.values(raceState.boats || {}).filter((b) => b.track && b.track.length);
|
|
1559
|
+
// Waypoints this same race already published back as its own start/
|
|
1560
|
+
// finish/marks (see publishCourseResources server-side) would otherwise
|
|
1561
|
+
// double up here — once as a plain waypoint pin, once as the actual
|
|
1562
|
+
// course element — so they're left out of the generic waypoint layer.
|
|
1563
|
+
const ownPrefix = `${raceState.name} — `;
|
|
1564
|
+
const otherWaypoints = waypoints.filter((w) => !w.name || !w.name.startsWith(ownPrefix));
|
|
1565
|
+
|
|
1566
|
+
const boundsPts = [];
|
|
1567
|
+
if (course.startLine) boundsPts.push(...course.startLine);
|
|
1568
|
+
if (course.finishLine) boundsPts.push(...course.finishLine);
|
|
1569
|
+
boundsPts.push(...course.marks);
|
|
1570
|
+
otherWaypoints.forEach((w) => boundsPts.push(w));
|
|
1571
|
+
boatsWithTrack.forEach((b) => boundsPts.push(...b.track));
|
|
1572
|
+
chartEmptyMsg.hidden = boundsPts.length > 0;
|
|
1573
|
+
|
|
1574
|
+
otherWaypoints.forEach((w) => {
|
|
1575
|
+
L.circleMarker(ll(w), { radius: 4, weight: 1, color: 'var(--muted)', fillColor: 'var(--panel)', fillOpacity: 0.6 })
|
|
1576
|
+
.bindTooltip(`<span style="color:var(--muted)">${escapeHtml(w.name)}</span>`, { permanent: true, direction: 'top', offset: [0, -6], className: 'waypoint-label' })
|
|
1577
|
+
.addTo(chartLayerGroup);
|
|
1578
|
+
});
|
|
1527
1579
|
|
|
1528
1580
|
const coursePts = [];
|
|
1529
|
-
if (course.startLine) coursePts.push(
|
|
1581
|
+
if (course.startLine) coursePts.push(midpoint(course.startLine[0], course.startLine[1]));
|
|
1530
1582
|
course.marks.forEach((m) => coursePts.push(m));
|
|
1531
|
-
if (course.finishLine) coursePts.push(
|
|
1583
|
+
if (course.finishLine) coursePts.push(midpoint(course.finishLine[0], course.finishLine[1]));
|
|
1532
1584
|
if (coursePts.length >= 2) {
|
|
1533
|
-
|
|
1534
|
-
parts.push(`<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4,4" />`);
|
|
1585
|
+
L.polyline(coursePts.map(ll), { color: 'var(--muted)', weight: 1.5, dashArray: '4,4' }).addTo(chartLayerGroup);
|
|
1535
1586
|
}
|
|
1536
1587
|
|
|
1537
1588
|
if (course.startLine) {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--good)" font-size="11" text-anchor="middle">Start</text>`);
|
|
1589
|
+
L.polyline(course.startLine.map(ll), { color: 'var(--good)', weight: 2 })
|
|
1590
|
+
.bindTooltip('<span style="color:var(--good)">Start</span>', { permanent: true, direction: 'center', className: 'mark-label' })
|
|
1591
|
+
.addTo(chartLayerGroup);
|
|
1542
1592
|
}
|
|
1543
1593
|
if (course.finishLine) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--accent)" font-size="11" text-anchor="middle">Finish</text>`);
|
|
1594
|
+
L.polyline(course.finishLine.map(ll), { color: 'var(--accent)', weight: 2 })
|
|
1595
|
+
.bindTooltip('<span style="color:var(--accent)">Finish</span>', { permanent: true, direction: 'center', className: 'mark-label' })
|
|
1596
|
+
.addTo(chartLayerGroup);
|
|
1548
1597
|
}
|
|
1549
1598
|
course.marks.forEach((m, i) => {
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1599
|
+
L.circleMarker(ll(m), { radius: 5, weight: 1.5, color: 'var(--text)', fillColor: 'var(--bg)', fillOpacity: 1 })
|
|
1600
|
+
.bindTooltip(`<span style="color:var(--text)">${escapeHtml(m.name || String(i + 1))}</span>`, { permanent: true, direction: 'top', offset: [0, -8], className: 'mark-label' })
|
|
1601
|
+
.addTo(chartLayerGroup);
|
|
1553
1602
|
});
|
|
1554
1603
|
|
|
1555
1604
|
let minT = Infinity;
|
|
@@ -1577,16 +1626,34 @@
|
|
|
1577
1626
|
// observation instead, same as before this existed.
|
|
1578
1627
|
const MAX_INTERP_GAP_MS = 5 * 60 * 1000;
|
|
1579
1628
|
|
|
1629
|
+
// A speed pickup between consecutive samples (gust, better trim,
|
|
1630
|
+
// planing) is more useful marked right on the trace than buried in a
|
|
1631
|
+
// separate speed graph. Drawn in the boat's own colour (like the rest of
|
|
1632
|
+
// its trace) rather than a fixed one, since a shared colour would clash
|
|
1633
|
+
// with whichever boat's own palette entry happens to match it; the ring
|
|
1634
|
+
// shape (unfilled, sized by how much faster) is what sets it apart from
|
|
1635
|
+
// the regular sample dots and the solid current-position dot.
|
|
1636
|
+
const SPEED_INCREASE_MS = 1.0; // m/s (~1.9 kn) between ~15s samples
|
|
1637
|
+
|
|
1580
1638
|
boatsWithTrack.forEach((b, idx) => {
|
|
1581
1639
|
const color = CHART_PALETTE[idx % CHART_PALETTE.length];
|
|
1582
1640
|
const pts = b.track.filter((pt) => pt.t <= cutoff);
|
|
1583
1641
|
if (!pts.length) return;
|
|
1584
|
-
|
|
1585
|
-
parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85" />`);
|
|
1642
|
+
L.polyline(pts.map(ll), { color, weight: 1.5, opacity: 0.85 }).addTo(chartLayerGroup);
|
|
1586
1643
|
pts.forEach((pt) => {
|
|
1587
|
-
|
|
1588
|
-
parts.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2" fill="${color}" opacity="0.7" />`);
|
|
1644
|
+
L.circleMarker(ll(pt), { radius: 2, weight: 0, color, fillColor: color, fillOpacity: 0.7 }).addTo(chartLayerGroup);
|
|
1589
1645
|
});
|
|
1646
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1647
|
+
const prevPt = pts[i - 1];
|
|
1648
|
+
const pt = pts[i];
|
|
1649
|
+
if (typeof prevPt.sog !== 'number' || typeof pt.sog !== 'number') continue;
|
|
1650
|
+
const delta = pt.sog - prevPt.sog;
|
|
1651
|
+
if (delta < SPEED_INCREASE_MS) continue;
|
|
1652
|
+
const r = Math.min(4 + delta * 2, 10);
|
|
1653
|
+
L.circleMarker(ll(pt), { radius: r, weight: 2, color, fill: false })
|
|
1654
|
+
.bindTooltip(`${escapeHtml(b.name)}: sped up to ${(pt.sog * 1.94384).toFixed(1)} kn`)
|
|
1655
|
+
.addTo(chartLayerGroup);
|
|
1656
|
+
}
|
|
1590
1657
|
|
|
1591
1658
|
const prev = pts[pts.length - 1];
|
|
1592
1659
|
let current = prev;
|
|
@@ -1599,16 +1666,22 @@
|
|
|
1599
1666
|
interpolated = true;
|
|
1600
1667
|
}
|
|
1601
1668
|
}
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
parts.push(`<text x="${(cur.x + 7).toFixed(1)}" y="${(cur.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
|
|
1669
|
+
const curOpts = interpolated
|
|
1670
|
+
? { radius: 4, color, weight: 1.5, dashArray: '2,1.5', fillColor: color, fillOpacity: 0.35 }
|
|
1671
|
+
: { radius: 4, color, weight: 0, fillColor: color, fillOpacity: 1 };
|
|
1672
|
+
L.circleMarker(ll(current), curOpts)
|
|
1673
|
+
.bindTooltip(`<span style="color:${color}">${escapeHtml(b.name)}</span>`, { permanent: true, direction: 'right', offset: [7, 0], className: 'boat-label' })
|
|
1674
|
+
.addTo(chartLayerGroup);
|
|
1609
1675
|
});
|
|
1610
1676
|
|
|
1611
|
-
|
|
1677
|
+
if (!chartFitDone && boundsPts.length) {
|
|
1678
|
+
if (boundsPts.length === 1) {
|
|
1679
|
+
map.setView(ll(boundsPts[0]), 15);
|
|
1680
|
+
} else {
|
|
1681
|
+
map.fitBounds(boundsPts.map(ll), { padding: [30, 30] });
|
|
1682
|
+
}
|
|
1683
|
+
chartFitDone = true;
|
|
1684
|
+
}
|
|
1612
1685
|
|
|
1613
1686
|
replayControls.hidden = boatsWithTrack.length === 0 || !isFinite(minT);
|
|
1614
1687
|
if (!replayControls.hidden && document.activeElement !== replaySlider) {
|
|
@@ -2373,7 +2446,13 @@
|
|
|
2373
2446
|
courseToggleBtn.addEventListener('click', () => {
|
|
2374
2447
|
courseBody.hidden = !courseBody.hidden;
|
|
2375
2448
|
courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
|
|
2376
|
-
if (!courseBody.hidden)
|
|
2449
|
+
if (!courseBody.hidden) {
|
|
2450
|
+
renderChart();
|
|
2451
|
+
// The map may have been created (or last sized) while its container
|
|
2452
|
+
// was hidden or a different size — Leaflet doesn't pick that up on
|
|
2453
|
+
// its own.
|
|
2454
|
+
if (chartMap) setTimeout(() => chartMap.invalidateSize(), 0);
|
|
2455
|
+
}
|
|
2377
2456
|
});
|
|
2378
2457
|
startTimerToggleBtn.addEventListener('click', () => {
|
|
2379
2458
|
startTimerBody.hidden = !startTimerBody.hidden;
|
package/public/index.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Race Control</title>
|
|
7
|
+
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
|
7
8
|
<link rel="stylesheet" href="style.css" />
|
|
8
9
|
</head>
|
|
9
10
|
<body>
|
|
@@ -86,7 +87,8 @@
|
|
|
86
87
|
<span id="courseStatusText" class="status"></span>
|
|
87
88
|
</div>
|
|
88
89
|
<div class="chart-wrap">
|
|
89
|
-
<
|
|
90
|
+
<div id="courseChart" class="chart-map"></div>
|
|
91
|
+
<p id="chartEmptyMsg" class="empty chart-empty-msg" hidden>Add a course, then start the race, to see the chart here</p>
|
|
90
92
|
<div id="replayControls" class="replay-controls" hidden>
|
|
91
93
|
<input type="range" id="replaySlider" min="0" max="100" value="100" />
|
|
92
94
|
<span id="replayTimeLabel">Live</span>
|
|
@@ -171,6 +173,7 @@
|
|
|
171
173
|
</div>
|
|
172
174
|
<p id="emptyMsg" class="empty" hidden>No boats added yet. Use "Add Boat" above to enter the fleet.</p>
|
|
173
175
|
</main>
|
|
176
|
+
<script src="vendor/leaflet/leaflet.js"></script>
|
|
174
177
|
<script src="app.js"></script>
|
|
175
178
|
</body>
|
|
176
179
|
</html>
|
package/public/style.css
CHANGED
|
@@ -398,17 +398,49 @@ main {
|
|
|
398
398
|
|
|
399
399
|
.chart-wrap {
|
|
400
400
|
margin-top: 1rem;
|
|
401
|
+
position: relative;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.chart-empty-msg {
|
|
405
|
+
position: absolute;
|
|
406
|
+
top: 50%;
|
|
407
|
+
left: 50%;
|
|
408
|
+
transform: translate(-50%, -50%);
|
|
409
|
+
margin: 0;
|
|
410
|
+
pointer-events: none;
|
|
411
|
+
z-index: 1000;
|
|
412
|
+
background: var(--panel);
|
|
413
|
+
padding: 0.5rem 0.9rem;
|
|
414
|
+
border-radius: 6px;
|
|
415
|
+
border: 1px solid var(--border);
|
|
401
416
|
}
|
|
402
417
|
|
|
403
|
-
|
|
418
|
+
.chart-map {
|
|
404
419
|
width: 100%;
|
|
405
420
|
max-width: 800px;
|
|
406
|
-
height:
|
|
421
|
+
height: 500px;
|
|
407
422
|
background: var(--panel);
|
|
408
423
|
border: 1px solid var(--border);
|
|
409
424
|
border-radius: 8px;
|
|
410
425
|
}
|
|
411
426
|
|
|
427
|
+
.chart-map .leaflet-tooltip.mark-label,
|
|
428
|
+
.chart-map .leaflet-tooltip.waypoint-label,
|
|
429
|
+
.chart-map .leaflet-tooltip.boat-label {
|
|
430
|
+
background: none;
|
|
431
|
+
border: none;
|
|
432
|
+
box-shadow: none;
|
|
433
|
+
padding: 0 0 0 4px;
|
|
434
|
+
font-size: 11px;
|
|
435
|
+
font-weight: 600;
|
|
436
|
+
white-space: nowrap;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.chart-map .leaflet-tooltip.waypoint-label {
|
|
440
|
+
font-weight: 400;
|
|
441
|
+
opacity: 0.85;
|
|
442
|
+
}
|
|
443
|
+
|
|
412
444
|
.replay-controls {
|
|
413
445
|
margin-top: 0.5rem;
|
|
414
446
|
display: flex;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
|
4
|
+
Copyright (c) 2010-2011, CloudMade
|
|
5
|
+
All rights reserved.
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
18
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
19
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
20
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
21
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
22
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
23
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
24
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
25
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
26
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|