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 +34 -10
- package/index.js +143 -17
- package/package.json +1 -1
- package/public/app.js +460 -69
- package/public/index.html +76 -42
- package/public/style.css +91 -17
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
it can be re-run — boats
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
316
|
-
//
|
|
317
|
-
// that
|
|
318
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1982
|
-
if (delay <= 0) {
|
|
2027
|
+
if (race.scheduledStart - Date.now() <= 0) {
|
|
1983
2028
|
doStart(race, race.scheduledStart);
|
|
1984
2029
|
} else {
|
|
1985
|
-
scheduleTimers.
|
|
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
|
-
|
|
2042
|
-
if (delay <= 0) {
|
|
2083
|
+
if (race.scheduledCallOff - Date.now() <= 0) {
|
|
2043
2084
|
doStop(race, race.scheduledCallOff);
|
|
2044
2085
|
} else {
|
|
2045
|
-
callOffTimers.
|
|
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.
|
|
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>",
|