signalk-race-control 0.10.0 → 0.11.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
@@ -113,6 +113,9 @@ and a live projected finishing order — during and after the race.
113
113
  navigation aid within 30m of wherever was clicked, it asks whether to snap to that
114
114
  aid's exact position and name instead of the raw click), and switches to **Use my
115
115
  position** once it has one, to re-centre it on wherever you are right now instead.
116
+ Once a navigation aid's been picked (or snapped to) anywhere in the course, it's
117
+ offered instantly in every other start/finish/mark row too — no repeat OpenSeaMap
118
+ lookup for the same one, e.g. reusing a rounding mark as the finish pin.
116
119
  Setting a position this way (map click or self
117
120
  position — not while still typing lat/lon by hand) also suggests a name for it from
118
121
  OpenStreetMap, when there's a real charted place right there and the name field is
@@ -123,7 +126,18 @@ and a live projected finishing order — during and after the race.
123
126
  available regardless). Every other known waypoint (not part of this race's own
124
127
  course) shows on the chart too, as a small dim pin with its name, so it's there to
125
128
  reference — or type into a mark's name field — even before it's used.
126
- The chart itself is a real background map, like freeboard-sk's: it uses whatever
129
+ The chart itself runs almost the full page width. Scrolling over it doesn't zoom it
130
+ by default — the page scrolls past normally, same as anywhere else on the page —
131
+ click it once to turn scroll-to-zoom on for as long as the mouse stays over it; move
132
+ off and scrolling past it again goes back to just scrolling the page. It auto-fits to
133
+ the course, waypoints, and boat tracks once when a race is first opened — panning or
134
+ zooming around after that doesn't get undone on its own; the ⊙ button under the zoom
135
+ controls re-centers on demand, scoped to just this race's own start/finish/marks (not
136
+ other waypoints or wherever the boats have since sailed to) — a stable "back to the
137
+ course" reset rather than one that shifts as the race goes on. With no course entered
138
+ yet, it centers on this vessel's own current position instead of doing nothing. It's a
139
+ real background map, like
140
+ freeboard-sk's: it uses whatever
127
141
  tile-based chart resource(s) this SignalK server has registered, or falls back to
128
142
  public OpenStreetMap tiles when none is configured (same fallback freeboard-sk itself
129
143
  uses). Registering more than one chart offers them as alternative base layers via the
@@ -254,30 +268,40 @@ Then restart the SignalK server, enable "Race Control" under
254
268
  boat if you want its AIS position tracked for the chart/estimate.
255
269
  3. Optionally expand **Course & chart** and enter the start line, marks (in rounding
256
270
  order — reorder with ↑/↓), and finish line, then **Save Course**.
257
- 4. Either click **Start Race** now, or set a date/time and click **Schedule Start** —
271
+ 4. For a staggered/pursuit start, optionally expand **Classes (staggered starts)** and
272
+ add a class per start group (e.g. "Cruisers", "Spinnaker"). Assign each boat to a
273
+ class from the dropdown in its row (or when adding it), then give each class its own
274
+ start time (Now, once the race has started, or type one directly). A boat with no
275
+ class assigned — or a class with no start time set yet — falls back to the race's own
276
+ start time, same as before classes existed. A boat's own **Start time** override (for
277
+ an individual correction) still wins over either.
278
+ 5. Either click **Start Race** now, or set a date/time and click **Schedule Start** —
258
279
  the race starts itself automatically at that moment (even across a server restart).
280
+ A real date's always needed here regardless of a single- vs. multi-day race, since a
281
+ start is often scheduled well ahead of race day itself.
259
282
  Clicked it a little late? Fix the recorded moment directly in the **Race start**
260
283
  field next to the clock (Now/Clear) — unlike Reset, it never touches any boat's
261
284
  finish time, DNF, or individual start override. If a particular boat actually
262
285
  started at a different moment (a staggered/pursuit start, or a correction), set
263
286
  its own time in the **Start time** column instead of leaving it to follow the
264
287
  race's start.
265
- 5. As boats finish, click **Now** to stamp the current time, or type the exact time
288
+ 6. As boats finish, click **Now** to stamp the current time, or type the exact time
266
289
  (plus date, for a multi-day race) into the finish-time field. **Clear** undoes a
267
290
  finish.
268
- 6. **Stop** calls the race off now (freezes the clock, DNFs whoever hasn't finished);
291
+ 7. **Stop** calls the race off now (freezes the clock, DNFs whoever hasn't finished);
269
292
  **Schedule Call-off** does the same at a future time instead. **Resume** discards
270
293
  a stop and un-DNFs whoever it DNF'd. **Reset** (click once to arm, again to
271
- confirm) clears this race's start/finish/DNF state and recorded tracks entirely so
272
- it can be re-run — boats and TCF values are kept.
273
- 7. Click a boat's ☆ to mark it **self** and see the **vs Self** column fill in for
294
+ confirm) clears this race's start/finish/DNF state, every class's start time, and
295
+ recorded tracks entirely so it can be re-run — boats, their class assignments, and
296
+ TCF values are kept.
297
+ 8. Click a boat's ☆ to mark it **self** and see the **vs Self** column fill in for
274
298
  every other boat.
275
- 8. **Export to Excel** downloads the current standings as a `.xlsx` file at any time
299
+ 9. **Export to Excel** downloads the current standings as a `.xlsx` file at any time
276
300
  — before, during, or after the race. **Download Offline Timer** grabs a standalone
277
301
  backup copy of the race instead — worth doing before the start if you want a safety
278
302
  net in case the server drops out mid-race.
279
- 9. Switch races anytime via the dropdown at the top to review an earlier race's
280
- results, or plan the next one. **Delete Race** (arm-then-confirm) removes one.
303
+ 10. Switch races anytime via the dropdown at the top to review an earlier race's
304
+ results, or plan the next one. **Delete Race** (arm-then-confirm) removes one.
281
305
 
282
306
  Corrected time is shown live throughout the race (using elapsed-so-far), and freezes
283
307
  once a boat's finish time is recorded. The **Est. finish** column shows a projected
package/index.js CHANGED
@@ -275,6 +275,10 @@ function makeBoatId() {
275
275
  return 'b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
276
276
  }
277
277
 
278
+ function makeClassId() {
279
+ return 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
280
+ }
281
+
278
282
  function raceSummary(race) {
279
283
  const boats = Object.values(race.boats);
280
284
  return {
@@ -301,6 +305,10 @@ function ensureRaceShape(race) {
301
305
  if (race.stopTime === undefined) race.stopTime = null;
302
306
  if (race.scheduledCallOff === undefined) race.scheduledCallOff = null;
303
307
  if (race.multiDay === undefined) race.multiDay = false;
308
+ if (!race.classes) race.classes = [];
309
+ race.classes.forEach((c) => {
310
+ if (c.startTime === undefined) c.startTime = null;
311
+ });
304
312
  Object.values(race.boats).forEach((b) => {
305
313
  if (!b.track) b.track = [];
306
314
  if (b.dnf === undefined) b.dnf = false;
@@ -308,16 +316,32 @@ function ensureRaceShape(race) {
308
316
  if (b.dnfPosition === undefined) b.dnfPosition = null;
309
317
  if (b.startTime === undefined) b.startTime = null;
310
318
  if (b.sailNumber === undefined) b.sailNumber = null;
319
+ if (b.classId === undefined) b.classId = null;
311
320
  if (!b.markTimes) b.markTimes = {};
312
321
  });
322
+ // A boat's classId can go stale (its class got deleted) — rather than
323
+ // check for that everywhere a class is looked up, just clear it here so
324
+ // every other boat.classId in memory is always either null or a real
325
+ // class.
326
+ const classIds = new Set(race.classes.map((c) => c.id));
327
+ Object.values(race.boats).forEach((b) => {
328
+ if (b.classId && !classIds.has(b.classId)) b.classId = null;
329
+ });
330
+ }
331
+
332
+ function findClass(race, classId) {
333
+ return (race.classes || []).find((c) => c.id === classId) || null;
313
334
  }
314
335
 
315
- // A boat with its own start time (a staggered/pursuit start, or a
316
- // correction for a boat that didn't actually start with the fleet) uses
317
- // that instead of the race's single start time — everyone else still just
318
- // uses race.startTime, unchanged from before this existed.
336
+ // A boat's own start time (a per-boat correction) wins if set; otherwise
337
+ // its class's start time (a staggered/pursuit start by class) if it's in
338
+ // one and that class has its own start time set; otherwise the race's
339
+ // single start time, same as before either of those existed.
319
340
  function effectiveStartTime(race, boat) {
320
- return boat.startTime != null ? boat.startTime : race.startTime;
341
+ if (boat.startTime != null) return boat.startTime;
342
+ const cls = boat.classId ? findClass(race, boat.classId) : null;
343
+ if (cls && cls.startTime != null) return cls.startTime;
344
+ return race.startTime;
321
345
  }
322
346
 
323
347
  const EARTH_RADIUS_NM = 3440.065;
@@ -1576,6 +1600,28 @@ module.exports = function (app) {
1576
1600
  let dataFile = null;
1577
1601
  const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
1578
1602
  const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
1603
+
1604
+ // setTimeout's delay is a 32-bit signed int internally — anything past
1605
+ // ~24.8 days silently wraps and fires almost immediately instead of
1606
+ // waiting (this bit a race scheduled weeks out: it — and, since its
1607
+ // call-off got armed right after, that too — fired within moments of
1608
+ // being scheduled, leaving the race already stopped/DNF'd). This chains
1609
+ // multiple max-length timeouts instead of one long one, re-checking the
1610
+ // actual remaining delay each time it fires so it also self-corrects
1611
+ // for clock changes over a long wait; `timers` is keyed by raceId so
1612
+ // disarming always clears whichever leg of the chain is currently
1613
+ // pending, the same way a single setTimeout's handle would.
1614
+ const MAX_TIMEOUT_MS = 2147483647;
1615
+ function scheduleAt(timers, raceId, atTime, fn) {
1616
+ const delay = Math.min(Math.max(atTime - Date.now(), 0), MAX_TIMEOUT_MS);
1617
+ timers.set(
1618
+ raceId,
1619
+ setTimeout(() => {
1620
+ if (Date.now() >= atTime) fn();
1621
+ else scheduleAt(timers, raceId, atTime, fn);
1622
+ }, delay)
1623
+ );
1624
+ }
1579
1625
  let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
1580
1626
  let ktkCache = { fetchedAt: 0, boats: [], sourceUrl: null };
1581
1627
 
@@ -1978,14 +2024,10 @@ module.exports = function (app) {
1978
2024
  function armSchedule(race) {
1979
2025
  disarmSchedule(race.id);
1980
2026
  if (race.scheduledStart && !race.startTime) {
1981
- const delay = race.scheduledStart - Date.now();
1982
- if (delay <= 0) {
2027
+ if (race.scheduledStart - Date.now() <= 0) {
1983
2028
  doStart(race, race.scheduledStart);
1984
2029
  } else {
1985
- scheduleTimers.set(
1986
- race.id,
1987
- setTimeout(() => doStart(race, race.scheduledStart), delay)
1988
- );
2030
+ scheduleAt(scheduleTimers, race.id, race.scheduledStart, () => doStart(race, race.scheduledStart));
1989
2031
  }
1990
2032
  }
1991
2033
  }
@@ -2038,14 +2080,10 @@ module.exports = function (app) {
2038
2080
  function armCallOffSchedule(race) {
2039
2081
  disarmCallOffSchedule(race.id);
2040
2082
  if (race.scheduledCallOff && race.startTime && !race.stopTime) {
2041
- const delay = race.scheduledCallOff - Date.now();
2042
- if (delay <= 0) {
2083
+ if (race.scheduledCallOff - Date.now() <= 0) {
2043
2084
  doStop(race, race.scheduledCallOff);
2044
2085
  } else {
2045
- callOffTimers.set(
2046
- race.id,
2047
- setTimeout(() => doStop(race, race.scheduledCallOff), delay)
2048
- );
2086
+ scheduleAt(callOffTimers, race.id, race.scheduledCallOff, () => doStop(race, race.scheduledCallOff));
2049
2087
  }
2050
2088
  }
2051
2089
  }
@@ -2415,6 +2453,9 @@ module.exports = function (app) {
2415
2453
  race.scheduledStart = null;
2416
2454
  race.stopTime = null;
2417
2455
  race.scheduledCallOff = null;
2456
+ (race.classes || []).forEach((c) => {
2457
+ c.startTime = null;
2458
+ });
2418
2459
  Object.values(race.boats).forEach((b) => {
2419
2460
  b.finishTime = null;
2420
2461
  b.startTime = null;
@@ -2565,6 +2606,8 @@ module.exports = function (app) {
2565
2606
  if (!isHandicapRegisterMatch(name) && registryEntry && registryEntry.tcf != null) {
2566
2607
  tcf = registryEntry.tcf;
2567
2608
  }
2609
+ const givenClassId = ((req.body && req.body.classId) || '').toString().trim();
2610
+ const classId = givenClassId && findClass(race, givenClassId) ? givenClassId : null;
2568
2611
  const boat = {
2569
2612
  id: makeBoatId(),
2570
2613
  name,
@@ -2573,6 +2616,7 @@ module.exports = function (app) {
2573
2616
  tcf,
2574
2617
  finishTime: null,
2575
2618
  startTime: null,
2619
+ classId,
2576
2620
  track: [],
2577
2621
  dnf: false,
2578
2622
  dns: false,
@@ -2595,6 +2639,88 @@ module.exports = function (app) {
2595
2639
  res.json({ ok: true });
2596
2640
  });
2597
2641
 
2642
+ // Classes group boats for a staggered start by class (e.g. "Cruisers
2643
+ // start at 12:00, Racers at 12:15") — an alternative to setting every
2644
+ // boat's own start time by hand. A boat's own start time (if it has
2645
+ // one) still wins over its class's, same as it already won over the
2646
+ // race's single start time — see effectiveStartTime.
2647
+ router.post('/races/:id/classes', (req, res) => {
2648
+ const race = getRace(req.params.id);
2649
+ if (!race) return res.status(404).json({ error: 'No such race' });
2650
+ const name = ((req.body && req.body.name) || '').trim();
2651
+ if (!name) return res.status(400).json({ error: 'name is required' });
2652
+ ensureRaceShape(race);
2653
+ const cls = { id: makeClassId(), name, startTime: null };
2654
+ race.classes.push(cls);
2655
+ saveState();
2656
+ res.json(raceWithEstimates(race));
2657
+ });
2658
+
2659
+ router.put('/races/:id/classes/:classId', (req, res) => {
2660
+ const race = getRace(req.params.id);
2661
+ if (!race) return res.status(404).json({ error: 'No such race' });
2662
+ ensureRaceShape(race);
2663
+ const cls = findClass(race, req.params.classId);
2664
+ if (!cls) return res.status(404).json({ error: 'No such class' });
2665
+ if ('name' in (req.body || {})) {
2666
+ const name = (req.body.name || '').trim();
2667
+ if (!name) return res.status(400).json({ error: 'name cannot be empty' });
2668
+ cls.name = name;
2669
+ }
2670
+ saveState();
2671
+ res.json(raceWithEstimates(race));
2672
+ });
2673
+
2674
+ // Sets (or, with startTime: null, clears) this class's own start time —
2675
+ // same pattern as a boat's own start time.
2676
+ router.put('/races/:id/classes/:classId/startTime', (req, res) => {
2677
+ const race = getRace(req.params.id);
2678
+ if (!race) return res.status(404).json({ error: 'No such race' });
2679
+ ensureRaceShape(race);
2680
+ const cls = findClass(race, req.params.classId);
2681
+ if (!cls) return res.status(404).json({ error: 'No such class' });
2682
+ const raw = req.body ? req.body.startTime : undefined;
2683
+ if (raw === null) {
2684
+ cls.startTime = null;
2685
+ } else {
2686
+ const t = Number(raw);
2687
+ if (!isFinite(t) || t <= 0) {
2688
+ return res.status(400).json({ error: 'startTime must be an epoch-millisecond timestamp or null' });
2689
+ }
2690
+ cls.startTime = t;
2691
+ }
2692
+ saveState();
2693
+ res.json(raceWithEstimates(race));
2694
+ });
2695
+
2696
+ router.delete('/races/:id/classes/:classId', (req, res) => {
2697
+ const race = getRace(req.params.id);
2698
+ if (!race) return res.status(404).json({ error: 'No such race' });
2699
+ ensureRaceShape(race);
2700
+ if (!findClass(race, req.params.classId)) return res.status(404).json({ error: 'No such class' });
2701
+ race.classes = race.classes.filter((c) => c.id !== req.params.classId);
2702
+ Object.values(race.boats).forEach((b) => {
2703
+ if (b.classId === req.params.classId) b.classId = null;
2704
+ });
2705
+ saveState();
2706
+ res.json(raceWithEstimates(race));
2707
+ });
2708
+
2709
+ // Sets (or, with classId: null, clears) a boat's class.
2710
+ router.put('/races/:id/boats/:boatId/class', (req, res) => {
2711
+ const race = getRace(req.params.id);
2712
+ if (!race) return res.status(404).json({ error: 'No such race' });
2713
+ const boat = getBoat(race, req.params.boatId);
2714
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
2715
+ const classId = (req.body || {}).classId;
2716
+ if (classId != null && !findClass(race, classId)) {
2717
+ return res.status(400).json({ error: 'No such class' });
2718
+ }
2719
+ boat.classId = classId || null;
2720
+ saveState();
2721
+ res.json(boat);
2722
+ });
2723
+
2598
2724
  // Sets (or, with finishTime: null, clears) a boat's finish time to an
2599
2725
  // arbitrary timestamp, so race committee can correct a mistimed click.
2600
2726
  router.put('/races/:id/boats/:boatId/finishTime', (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.10.0",
3
+ "version": "0.11.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>",