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 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). 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
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. The course is also published as SignalK
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'] { 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.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
- // Simple equirectangular fit (longitude scaled by cos(mean latitude) so
1481
- // the small area a race course covers looks roughly to-scale) mapped into
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 renderChart() {
1502
- if (!raceState) {
1503
- courseChart.innerHTML = '';
1504
- replayControls.hidden = true;
1505
- return;
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: '&copy; <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: '&copy; <a href="https://www.openseamap.org">OpenSeaMap</a>'
1536
+ }).addTo(map);
1506
1537
  }
1507
- const course = raceState.course || { startLine: null, marks: [], finishLine: null };
1508
- const boatsWithTrack = Object.values(raceState.boats || {}).filter((b) => b.track && b.track.length);
1538
+ }
1509
1539
 
1510
- const allPoints = [];
1511
- if (course.startLine) allPoints.push(...course.startLine);
1512
- if (course.finishLine) allPoints.push(...course.finishLine);
1513
- allPoints.push(...course.marks);
1514
- boatsWithTrack.forEach((b) => allPoints.push(...b.track));
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 (allPoints.length < 2) {
1517
- courseChart.innerHTML =
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 width = 800;
1524
- const height = 500;
1525
- const proj = buildProjection(allPoints, width, height, 40);
1526
- const parts = [];
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({ lat: (course.startLine[0].lat + course.startLine[1].lat) / 2, lon: (course.startLine[0].lon + course.startLine[1].lon) / 2 });
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({ lat: (course.finishLine[0].lat + course.finishLine[1].lat) / 2, lon: (course.finishLine[0].lon + course.finishLine[1].lon) / 2 });
1583
+ if (course.finishLine) coursePts.push(midpoint(course.finishLine[0], course.finishLine[1]));
1532
1584
  if (coursePts.length >= 2) {
1533
- const d = coursePts.map((p, i) => (i === 0 ? 'M' : 'L') + proj(p).x.toFixed(1) + ',' + proj(p).y.toFixed(1)).join(' ');
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
- const a = proj(course.startLine[0]);
1539
- const b = proj(course.startLine[1]);
1540
- parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--good)" stroke-width="2" />`);
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
- const a = proj(course.finishLine[0]);
1545
- const b = proj(course.finishLine[1]);
1546
- parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--accent)" stroke-width="2" />`);
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
- const p = proj(m);
1551
- parts.push(`<circle cx="${p.x}" cy="${p.y}" r="5" fill="var(--bg)" stroke="var(--text)" stroke-width="1.5" />`);
1552
- parts.push(`<text x="${p.x}" y="${p.y - 10}" fill="var(--text)" font-size="11" text-anchor="middle">${escapeHtml(m.name || String(i + 1))}</text>`);
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
- const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
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
- const p = proj(pt);
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 cur = proj(current);
1603
- if (interpolated) {
1604
- parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" fill-opacity="0.35" stroke="${color}" stroke-width="1.5" stroke-dasharray="2,1.5" />`);
1605
- } else {
1606
- parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" />`);
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
- courseChart.innerHTML = parts.join('');
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) renderChart();
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
- <svg id="courseChart" viewBox="0 0 800 500" preserveAspectRatio="xMidYMid meet"></svg>
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
- #courseChart {
418
+ .chart-map {
404
419
  width: 100%;
405
420
  max-width: 800px;
406
- height: auto;
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.