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 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** — enter lat/lon for the start line, an ordered list of rounding
103
- marks, and the finish line (or click **Use my position** if you're sitting at that
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). The
106
- webapp draws them on a built-in chart, overlaid with each AIS-tracked boat's
107
- recorded track for the current race — drag the **replay** slider to step back
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. The course is also published as SignalK
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'] { flex: 1; min-width: 12rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
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
- <input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" list="addBoatSuggestions" />
543
- <datalist id="addBoatSuggestions"></datalist>
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
- var opt = document.createElement('option');
760
- opt.value = b.name;
761
- addBoatSuggestions.appendChild(opt);
770
+ nameSuggestionPool.push({ name: b.name });
771
+ });
772
+ }
773
+ function escapeHtml(s) {
774
+ return s.replace(/[&<>"']/g, function (c) {
775
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 === 'Enter') { e.preventDefault(); addBoat(); }
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 = (id, name, pt) => app.resourcesApi.setResource('waypoints', id, waypointResource(name, pt));
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`, routeResource(race.name, points));
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.7.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>",