signalk-race-control 0.4.2 → 0.4.3
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 +39 -8
- package/index.js +132 -12
- package/package.json +2 -1
- package/public/app.js +304 -7
- package/public/index.html +4 -0
- package/public/style.css +43 -1
package/README.md
CHANGED
|
@@ -75,19 +75,46 @@ and a live projected finishing order — during and after the race.
|
|
|
75
75
|
**Clear** to undo. Handles races that cross midnight.
|
|
76
76
|
- **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
|
|
77
77
|
marks, and the finish line (or click **Use my position** if you're sitting at that
|
|
78
|
-
spot
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
|
|
79
|
+
already placed from a chart plotter — and pick one to fill in its position). The
|
|
80
|
+
webapp draws them on a built-in chart, overlaid with each AIS-tracked boat's
|
|
81
|
+
recorded track for the current race — drag the **replay** slider to step back
|
|
82
|
+
through it, or leave it on **Live**. Click **Play** to have it step through on its own
|
|
83
|
+
instead, at whatever speed the adjoining slider is set to (1x-60x); it starts over
|
|
84
|
+
from the earliest recorded position when played from Live (there's nothing to play
|
|
85
|
+
forward into from there), stops on its own once it catches up to the latest one, and
|
|
86
|
+
dragging the main slider or clicking **Live** stops it early. Every actual recorded
|
|
87
|
+
position (an AIS sample,
|
|
88
|
+
or a manually-recorded mark rounding) shows as a small dot along the track, so it's
|
|
89
|
+
clear which points are real; wherever the slider sits between two of them, the boat's
|
|
90
|
+
position is linearly interpolated and drawn as a hollow ring instead of a solid dot,
|
|
91
|
+
so an in-between position always reads as estimated rather than observed. It falls
|
|
92
|
+
back to just the last real position (no ring) instead of interpolating across a gap
|
|
93
|
+
longer than 5 minutes — AIS dropping out, or a mark rounding recorded far from any
|
|
94
|
+
real fix — since a straight line across a gap that long would be a guess, not an
|
|
95
|
+
estimate. The course is also published as SignalK
|
|
81
96
|
waypoint/route resources for any chart plotter (e.g. freeboard-sk) that reads the
|
|
82
|
-
standard resources API —
|
|
83
|
-
|
|
97
|
+
standard resources API — both directions (reading existing waypoints for the
|
|
98
|
+
autocomplete, and publishing the saved course) only do anything if your server has a
|
|
99
|
+
resources provider installed; they're a no-op otherwise, never a failure.
|
|
84
100
|
- **Estimated finish time & live rank** — while a boat is still racing, if it has a
|
|
85
101
|
live AIS position and speed and the race has a finish line, the plugin projects a
|
|
86
102
|
finish time and corrected time from its remaining distance and speed, and ranks it
|
|
87
103
|
accordingly. The distance routes around whichever marks the boat hasn't rounded yet
|
|
88
|
-
(detected automatically from its recorded track passing within
|
|
89
|
-
|
|
90
|
-
that detection actually
|
|
104
|
+
(detected automatically from its recorded track passing within a configurable radius
|
|
105
|
+
— `markRoundingRadiusM`, 100m by default — of each mark in order) rather than
|
|
106
|
+
cutting straight to the finish — accuracy still depends on that detection actually
|
|
107
|
+
catching each rounding. A rounding can also be recorded by hand: each boat's row has
|
|
108
|
+
a numbered pill per mark in the **Marks** column — click one to mark it rounded right
|
|
109
|
+
now, click again to clear it — for a boat with no MMSI/AIS at all, or to correct one
|
|
110
|
+
the automatic detection missed. Either source counts toward the same rounding, and
|
|
111
|
+
for a boat still racing with no AIS-based estimate to rank by, marks rounded (by
|
|
112
|
+
either method) takes priority over raw elapsed time, so a boat further round the
|
|
113
|
+
course still ranks ahead even without live tracking. A manual rounding also records a
|
|
114
|
+
position for the replay chart — the boat's live position if it has AIS, otherwise the
|
|
115
|
+
mark's own position (a reasonable stand-in, since rounding a mark means being at it)
|
|
116
|
+
— so even a boat with no AIS at all shows up on the chart at each mark it's recorded
|
|
117
|
+
rounding.
|
|
91
118
|
- **Stop / call off the race** — freezes elapsed/corrected time for everyone without
|
|
92
119
|
touching boats, finish times, or the course (unlike Reset, which clears the race
|
|
93
120
|
back to not-started), and marks every boat that hadn't finished as **DNF**,
|
|
@@ -200,3 +227,7 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
|
|
|
200
227
|
setting, not a per-race or webapp-side option — change it under
|
|
201
228
|
**Server → Plugin Config → Race Control** and restart the plugin (the server does
|
|
202
229
|
this automatically on save) for the webapp to pick it up.
|
|
230
|
+
- The mark-rounding radius used for the estimated finish time and remaining-distance
|
|
231
|
+
calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
|
|
232
|
+
loosen it for a fleet with noisier AIS tracks, or tighten it if marks sit close
|
|
233
|
+
together and a boat's radius circles are overlapping.
|
package/index.js
CHANGED
|
@@ -251,6 +251,7 @@ function ensureRaceShape(race) {
|
|
|
251
251
|
if (b.dnfPosition === undefined) b.dnfPosition = null;
|
|
252
252
|
if (b.startTime === undefined) b.startTime = null;
|
|
253
253
|
if (b.sailNumber === undefined) b.sailNumber = null;
|
|
254
|
+
if (!b.markTimes) b.markTimes = {};
|
|
254
255
|
});
|
|
255
256
|
}
|
|
256
257
|
|
|
@@ -1329,6 +1330,12 @@ module.exports = function (app) {
|
|
|
1329
1330
|
title:
|
|
1330
1331
|
'Allow importing a complete fleet (every boat, with TCF) into a race from an external regatta system — currently Manage2Sail. Off by default, since it fetches from a third-party site and bulk-adds boats.',
|
|
1331
1332
|
default: false
|
|
1333
|
+
},
|
|
1334
|
+
markRoundingRadiusM: {
|
|
1335
|
+
type: 'number',
|
|
1336
|
+
title:
|
|
1337
|
+
'How close (in meters) an AIS-tracked boat must come to a mark for it to count as rounded, for the estimated finish time and remaining-distance calculations',
|
|
1338
|
+
default: 100
|
|
1332
1339
|
}
|
|
1333
1340
|
}
|
|
1334
1341
|
};
|
|
@@ -1453,29 +1460,58 @@ module.exports = function (app) {
|
|
|
1453
1460
|
}
|
|
1454
1461
|
|
|
1455
1462
|
// A mark counts as "rounded" once the boat's recorded track came within
|
|
1456
|
-
// this radius of it
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
|
|
1463
|
+
// this radius of it — meters, configurable (plugin config,
|
|
1464
|
+
// markRoundingRadiusM), converted to nautical miles for distanceNm().
|
|
1465
|
+
// 100m by default — loose enough to tolerate AIS position jitter and the
|
|
1466
|
+
// 15s sampling gap (a boat doing 7kn covers about 55m between samples)
|
|
1467
|
+
// without needing an exact pass.
|
|
1468
|
+
function markRoundingRadiusNm() {
|
|
1469
|
+
const meters = (plugin.options && plugin.options.markRoundingRadiusM) || 100;
|
|
1470
|
+
return meters / 1852;
|
|
1471
|
+
}
|
|
1460
1472
|
|
|
1461
1473
|
// Scans a boat's recorded track chronologically, advancing to the next
|
|
1462
1474
|
// mark each time the track comes within rounding radius of the current
|
|
1463
1475
|
// one — so it naturally requires marks to be rounded in course order.
|
|
1464
1476
|
// Automatic (no manual "boat X rounded mark Y" input) by design, at the
|
|
1465
1477
|
// cost of missing a rounding if a boat cuts far outside the radius.
|
|
1466
|
-
function
|
|
1478
|
+
function countAutoRoundedMarks(race, boat) {
|
|
1467
1479
|
const marks = race.course.marks;
|
|
1468
1480
|
if (!marks.length || !boat.track || !boat.track.length) return 0;
|
|
1481
|
+
const radiusNm = markRoundingRadiusNm();
|
|
1469
1482
|
let markIdx = 0;
|
|
1470
1483
|
for (const pt of boat.track) {
|
|
1471
1484
|
if (markIdx >= marks.length) break;
|
|
1472
|
-
if (distanceNm(pt, marks[markIdx]) <=
|
|
1485
|
+
if (distanceNm(pt, marks[markIdx]) <= radiusNm) {
|
|
1473
1486
|
markIdx++;
|
|
1474
1487
|
}
|
|
1475
1488
|
}
|
|
1476
1489
|
return markIdx;
|
|
1477
1490
|
}
|
|
1478
1491
|
|
|
1492
|
+
// A committee member can also record a mark rounding by hand (with its
|
|
1493
|
+
// own timestamp) — for a boat with no MMSI/AIS at all, or to correct a
|
|
1494
|
+
// rounding the automatic track-based detection above missed. Counted the
|
|
1495
|
+
// same "in order" way as the automatic detection: a mark only counts if
|
|
1496
|
+
// every mark before it is also recorded, manually or automatically, so a
|
|
1497
|
+
// rounding can't be marked out of course order.
|
|
1498
|
+
function countManualRoundedMarks(race, boat) {
|
|
1499
|
+
const marks = race.course.marks;
|
|
1500
|
+
if (!marks.length || !boat.markTimes) return 0;
|
|
1501
|
+
let count = 0;
|
|
1502
|
+
for (const m of marks) {
|
|
1503
|
+
if (boat.markTimes[m.id] == null) break;
|
|
1504
|
+
count++;
|
|
1505
|
+
}
|
|
1506
|
+
return count;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// The higher of the two — a boat only needs one working way to record a
|
|
1510
|
+
// rounding, not both.
|
|
1511
|
+
function countRoundedMarks(race, boat) {
|
|
1512
|
+
return Math.max(countAutoRoundedMarks(race, boat), countManualRoundedMarks(race, boat));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1479
1515
|
// Distance from the boat's current position, around each remaining mark
|
|
1480
1516
|
// in order, to the finish line — not just a straight line to the finish.
|
|
1481
1517
|
// Returns null if there's nothing left to route to (no remaining marks and
|
|
@@ -1523,12 +1559,16 @@ module.exports = function (app) {
|
|
|
1523
1559
|
};
|
|
1524
1560
|
}
|
|
1525
1561
|
|
|
1526
|
-
// Attaches a live `estimate` to each unfinished
|
|
1527
|
-
// stored race —
|
|
1562
|
+
// Attaches a live `estimate` and `roundedMarksCount` to each unfinished
|
|
1563
|
+
// boat without mutating the stored race — both are derived from live/
|
|
1564
|
+
// recorded data, never persisted as such (roundedMarksCount is derived
|
|
1565
|
+
// from markTimes, which is persisted; the count itself isn't).
|
|
1528
1566
|
function raceWithEstimates(race) {
|
|
1529
1567
|
const out = JSON.parse(JSON.stringify(race));
|
|
1530
1568
|
Object.values(out.boats).forEach((b) => {
|
|
1531
|
-
|
|
1569
|
+
const boat = race.boats[b.id];
|
|
1570
|
+
b.estimate = estimateFinish(race, boat);
|
|
1571
|
+
b.roundedMarksCount = countRoundedMarks(race, boat);
|
|
1532
1572
|
});
|
|
1533
1573
|
return out;
|
|
1534
1574
|
}
|
|
@@ -1546,13 +1586,24 @@ module.exports = function (app) {
|
|
|
1546
1586
|
const tcf = boat.tcf != null ? boat.tcf : 1.0;
|
|
1547
1587
|
const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
|
|
1548
1588
|
const estimate = estimateFinish(race, boat);
|
|
1589
|
+
const roundedMarksCount = countRoundedMarks(race, boat);
|
|
1549
1590
|
const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
|
|
1550
|
-
return { boat, elapsedMs, correctedMs, estimate, rankMs };
|
|
1591
|
+
return { boat, elapsedMs, correctedMs, estimate, roundedMarksCount, rankMs };
|
|
1551
1592
|
})
|
|
1552
1593
|
.sort((a, b) => {
|
|
1553
1594
|
if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
|
|
1554
1595
|
if (a.rankMs == null) return 1;
|
|
1555
1596
|
if (b.rankMs == null) return -1;
|
|
1597
|
+
// Among still-racing boats with no AIS-based ETA to fall back on
|
|
1598
|
+
// (elapsed-so-far alone says nothing about how much course is
|
|
1599
|
+
// left), a boat recorded further around the course — manually or
|
|
1600
|
+
// automatically — ranks ahead regardless of corrected time so far.
|
|
1601
|
+
// Boats WITH an estimate already have marks-remaining baked into
|
|
1602
|
+
// estCorrectedMs via the distance routing, so this only applies
|
|
1603
|
+
// when neither side has one.
|
|
1604
|
+
if (!a.boat.finishTime && !b.boat.finishTime && !a.estimate && !b.estimate && a.roundedMarksCount !== b.roundedMarksCount) {
|
|
1605
|
+
return b.roundedMarksCount - a.roundedMarksCount;
|
|
1606
|
+
}
|
|
1556
1607
|
return a.rankMs - b.rankMs;
|
|
1557
1608
|
});
|
|
1558
1609
|
}
|
|
@@ -2188,7 +2239,8 @@ module.exports = function (app) {
|
|
|
2188
2239
|
startTime: null,
|
|
2189
2240
|
track: [],
|
|
2190
2241
|
dnf: false,
|
|
2191
|
-
dnfPosition: null
|
|
2242
|
+
dnfPosition: null,
|
|
2243
|
+
markTimes: {}
|
|
2192
2244
|
};
|
|
2193
2245
|
race.boats[boat.id] = boat;
|
|
2194
2246
|
saveState();
|
|
@@ -2274,6 +2326,47 @@ module.exports = function (app) {
|
|
|
2274
2326
|
res.json(boat);
|
|
2275
2327
|
});
|
|
2276
2328
|
|
|
2329
|
+
// Manual mark-rounding: a committee member records (or clears) the
|
|
2330
|
+
// moment a boat rounded a specific mark, independent of the automatic
|
|
2331
|
+
// AIS track-based detection — for a boat with no MMSI/AIS at all, or to
|
|
2332
|
+
// correct a rounding the automatic detection missed. See
|
|
2333
|
+
// countManualRoundedMarks/countRoundedMarks for how the two combine.
|
|
2334
|
+
router.put('/races/:id/boats/:boatId/markTimes/:markId', (req, res) => {
|
|
2335
|
+
const race = getRace(req.params.id);
|
|
2336
|
+
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
2337
|
+
const boat = getBoat(race, req.params.boatId);
|
|
2338
|
+
if (!boat) return res.status(404).json({ error: 'No such boat' });
|
|
2339
|
+
const mark = race.course.marks.find((m) => m.id === req.params.markId);
|
|
2340
|
+
if (!mark) return res.status(404).json({ error: 'No such mark' });
|
|
2341
|
+
if (!boat.track) boat.track = [];
|
|
2342
|
+
// Drop any earlier manually-added point for this exact mark first,
|
|
2343
|
+
// whether we're about to replace it or just clearing — tagged with
|
|
2344
|
+
// markId so this can never touch a genuine AIS-recorded sample.
|
|
2345
|
+
boat.track = boat.track.filter((pt) => pt.markId !== mark.id);
|
|
2346
|
+
const raw = req.body ? req.body.time : undefined;
|
|
2347
|
+
if (raw === null) {
|
|
2348
|
+
delete boat.markTimes[mark.id];
|
|
2349
|
+
} else {
|
|
2350
|
+
const t = Number(raw);
|
|
2351
|
+
if (!isFinite(t) || t <= 0) {
|
|
2352
|
+
return res.status(400).json({ error: 'time must be an epoch-millisecond timestamp or null' });
|
|
2353
|
+
}
|
|
2354
|
+
boat.markTimes[mark.id] = t;
|
|
2355
|
+
// Record where the boat was at that moment too — its live position
|
|
2356
|
+
// if we have one (accurate, and consistent with its recorded AIS
|
|
2357
|
+
// track), otherwise the mark's own position as a reasonable stand-
|
|
2358
|
+
// in (rounding a mark means being at it). Either way this is what
|
|
2359
|
+
// lets a manually-recorded rounding show up on the replay chart,
|
|
2360
|
+
// even for a boat with no AIS at all.
|
|
2361
|
+
const live = getLivePosition(boat.mmsi);
|
|
2362
|
+
const pos = live || { lat: mark.lat, lon: mark.lon };
|
|
2363
|
+
boat.track.push({ t, lat: pos.lat, lon: pos.lon, markId: mark.id });
|
|
2364
|
+
boat.track.sort((a, b) => a.t - b.t);
|
|
2365
|
+
}
|
|
2366
|
+
saveState();
|
|
2367
|
+
res.json(boat);
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2277
2370
|
router.put('/races/:id/boats/:boatId/mmsi', (req, res) => {
|
|
2278
2371
|
const race = getRace(req.params.id);
|
|
2279
2372
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
@@ -2309,6 +2402,32 @@ module.exports = function (app) {
|
|
|
2309
2402
|
res.json({ enabled: isVetEnabled() });
|
|
2310
2403
|
});
|
|
2311
2404
|
|
|
2405
|
+
// Lets the course editor offer existing SignalK waypoints (e.g. ones
|
|
2406
|
+
// already placed on a chart plotter) as start/mark/finish positions,
|
|
2407
|
+
// instead of only typing lat/lon by hand. Best-effort, same spirit as
|
|
2408
|
+
// publishCourseResources — silently returns none if this server has no
|
|
2409
|
+
// resources provider registered, rather than failing the whole course
|
|
2410
|
+
// editor over it.
|
|
2411
|
+
router.get('/waypoints', async (req, res) => {
|
|
2412
|
+
if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') {
|
|
2413
|
+
return res.json({ waypoints: [] });
|
|
2414
|
+
}
|
|
2415
|
+
try {
|
|
2416
|
+
const data = await app.resourcesApi.listResources('waypoints', {});
|
|
2417
|
+
const waypoints = Object.keys(data || {})
|
|
2418
|
+
.map((id) => {
|
|
2419
|
+
const r = data[id] || {};
|
|
2420
|
+
const coords = r.feature && r.feature.geometry && r.feature.geometry.coordinates;
|
|
2421
|
+
if (!Array.isArray(coords) || coords.length < 2) return null;
|
|
2422
|
+
return { id, name: r.name || id, lon: coords[0], lat: coords[1] };
|
|
2423
|
+
})
|
|
2424
|
+
.filter(Boolean);
|
|
2425
|
+
res.json({ waypoints });
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
res.json({ waypoints: [] });
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2312
2431
|
router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
|
|
2313
2432
|
const race = getRace(req.params.id);
|
|
2314
2433
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
@@ -2428,7 +2547,8 @@ module.exports = function (app) {
|
|
|
2428
2547
|
startTime: null,
|
|
2429
2548
|
track: [],
|
|
2430
2549
|
dnf: false,
|
|
2431
|
-
dnfPosition: null
|
|
2550
|
+
dnfPosition: null,
|
|
2551
|
+
markTimes: {}
|
|
2432
2552
|
};
|
|
2433
2553
|
race.boats[boat.id] = boat;
|
|
2434
2554
|
existingByName.set(key, boat);
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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
|
+
"author": "Joachim Bakke <github@heiamoss.com>",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"signalk-node-server-plugin",
|
|
8
9
|
"signalk-webapp",
|
package/public/app.js
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
let lastCourseFormRaceId = undefined; // tracks which race the course form reflects
|
|
25
25
|
let replayLive = true;
|
|
26
26
|
let replayTime = Date.now();
|
|
27
|
+
let replayPlaying = false;
|
|
28
|
+
let replaySpeed = 10; // 1x-60x, simulated seconds of replay time per real second
|
|
29
|
+
let replayPlayTimer = null;
|
|
30
|
+
let replayPlayLastTick = null;
|
|
31
|
+
let waypoints = []; // [{id, name, lat, lon}] from SignalK resources, for the course editor's "Pick a waypoint" dropdown
|
|
27
32
|
|
|
28
33
|
const raceSelect = document.getElementById('raceSelect');
|
|
29
34
|
const newRaceBtn = document.getElementById('newRaceBtn');
|
|
@@ -76,6 +81,9 @@
|
|
|
76
81
|
const replayControls = document.getElementById('replayControls');
|
|
77
82
|
const replaySlider = document.getElementById('replaySlider');
|
|
78
83
|
const replayTimeLabel = document.getElementById('replayTimeLabel');
|
|
84
|
+
const replayPlayBtn = document.getElementById('replayPlayBtn');
|
|
85
|
+
const replaySpeedSlider = document.getElementById('replaySpeedSlider');
|
|
86
|
+
const replaySpeedLabel = document.getElementById('replaySpeedLabel');
|
|
79
87
|
const replayLiveBtn = document.getElementById('replayLiveBtn');
|
|
80
88
|
const raceImportSection = document.getElementById('raceImportSection');
|
|
81
89
|
const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
|
|
@@ -363,6 +371,19 @@
|
|
|
363
371
|
}
|
|
364
372
|
}
|
|
365
373
|
|
|
374
|
+
// Best-effort, like the underlying resourcesApi calls it wraps — an empty
|
|
375
|
+
// list (no resources provider registered, or the request fails) just
|
|
376
|
+
// means the course editor's "Pick a waypoint" dropdowns have nothing to
|
|
377
|
+
// offer, not a failure worth surfacing.
|
|
378
|
+
async function loadWaypoints() {
|
|
379
|
+
try {
|
|
380
|
+
const data = await fetchJSON(`${API}/waypoints`);
|
|
381
|
+
waypoints = data.waypoints || [];
|
|
382
|
+
} catch (e) {
|
|
383
|
+
waypoints = [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
366
387
|
async function loadHandicapRegister(force) {
|
|
367
388
|
if (!vetEnabled) return;
|
|
368
389
|
try {
|
|
@@ -408,6 +429,11 @@
|
|
|
408
429
|
if (id !== lastCourseFormRaceId) {
|
|
409
430
|
loadCourseFormFromRace();
|
|
410
431
|
lastCourseFormRaceId = id;
|
|
432
|
+
// A running replay is specific to whichever race's track it was
|
|
433
|
+
// playing through — switching races (or to none) leaves it with
|
|
434
|
+
// nothing sensible to keep advancing into.
|
|
435
|
+
stopReplayPlayback();
|
|
436
|
+
replayLive = true;
|
|
411
437
|
}
|
|
412
438
|
}
|
|
413
439
|
|
|
@@ -757,6 +783,28 @@
|
|
|
757
783
|
}
|
|
758
784
|
}
|
|
759
785
|
|
|
786
|
+
// Records (or, with ts: null, clears) a boat's own manually-entered
|
|
787
|
+
// rounding time for one specific mark — independent of the automatic
|
|
788
|
+
// AIS-track-based detection, for a boat with no MMSI/AIS at all or to
|
|
789
|
+
// correct a rounding the automatic detection missed.
|
|
790
|
+
async function setMarkTime(boatId, markId, ts) {
|
|
791
|
+
if (!activeRaceId) return;
|
|
792
|
+
try {
|
|
793
|
+
const boat = await fetchJSON(
|
|
794
|
+
`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/markTimes/${encodeURIComponent(markId)}`,
|
|
795
|
+
{
|
|
796
|
+
method: 'PUT',
|
|
797
|
+
headers: { 'Content-Type': 'application/json' },
|
|
798
|
+
body: JSON.stringify({ time: ts })
|
|
799
|
+
}
|
|
800
|
+
);
|
|
801
|
+
raceState.boats[boatId] = boat;
|
|
802
|
+
render();
|
|
803
|
+
} catch (e) {
|
|
804
|
+
setStatus(e.message, true);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
760
808
|
async function setMmsi(boatId, mmsi) {
|
|
761
809
|
if (!activeRaceId) return;
|
|
762
810
|
try {
|
|
@@ -829,18 +877,119 @@
|
|
|
829
877
|
return null;
|
|
830
878
|
}
|
|
831
879
|
|
|
880
|
+
// Generic substring-match autocomplete, wiring `input` to `dropdown` (a
|
|
881
|
+
// hidden .suggestions element already sitting next to it in the DOM) —
|
|
882
|
+
// same matching/keyboard-nav behavior as the boat-name autocomplete
|
|
883
|
+
// above, but with its own closure-local item/selection state so several
|
|
884
|
+
// instances (one per course point row) can coexist without stepping on
|
|
885
|
+
// each other the way sharing the boat autocomplete's module-level state
|
|
886
|
+
// would.
|
|
887
|
+
function attachAutocomplete(input, dropdown, getPool, onSelect) {
|
|
888
|
+
let items = [];
|
|
889
|
+
let activeIndex = -1;
|
|
890
|
+
|
|
891
|
+
function hide() {
|
|
892
|
+
dropdown.hidden = true;
|
|
893
|
+
dropdown.innerHTML = '';
|
|
894
|
+
items = [];
|
|
895
|
+
activeIndex = -1;
|
|
896
|
+
}
|
|
897
|
+
function renderActive() {
|
|
898
|
+
Array.from(dropdown.children).forEach((el, i) => el.classList.toggle('active', i === activeIndex));
|
|
899
|
+
}
|
|
900
|
+
function select(item) {
|
|
901
|
+
onSelect(item);
|
|
902
|
+
hide();
|
|
903
|
+
}
|
|
904
|
+
function showFor(query) {
|
|
905
|
+
const q = query.trim().toLowerCase();
|
|
906
|
+
if (!q) {
|
|
907
|
+
hide();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const matches = getPool()
|
|
911
|
+
.filter((it) => it.name.toLowerCase().includes(q))
|
|
912
|
+
.slice(0, 20);
|
|
913
|
+
items = matches;
|
|
914
|
+
activeIndex = -1;
|
|
915
|
+
if (!matches.length) {
|
|
916
|
+
hide();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
dropdown.innerHTML = '';
|
|
920
|
+
matches.forEach((item) => {
|
|
921
|
+
const div = document.createElement('div');
|
|
922
|
+
div.className = 'suggestion-item';
|
|
923
|
+
const idx = item.name.toLowerCase().indexOf(q);
|
|
924
|
+
if (idx === -1) {
|
|
925
|
+
div.textContent = item.name;
|
|
926
|
+
} else {
|
|
927
|
+
div.innerHTML =
|
|
928
|
+
escapeHtml(item.name.slice(0, idx)) +
|
|
929
|
+
'<mark>' +
|
|
930
|
+
escapeHtml(item.name.slice(idx, idx + q.length)) +
|
|
931
|
+
'</mark>' +
|
|
932
|
+
escapeHtml(item.name.slice(idx + q.length));
|
|
933
|
+
}
|
|
934
|
+
// mousedown (not click) fires before the input's blur, same reason
|
|
935
|
+
// as the boat-name suggestions above.
|
|
936
|
+
div.addEventListener('mousedown', (e) => {
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
select(item);
|
|
939
|
+
});
|
|
940
|
+
dropdown.appendChild(div);
|
|
941
|
+
});
|
|
942
|
+
dropdown.hidden = false;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
input.addEventListener('input', () => showFor(input.value));
|
|
946
|
+
input.addEventListener('focus', () => {
|
|
947
|
+
if (input.value.trim()) showFor(input.value);
|
|
948
|
+
});
|
|
949
|
+
input.addEventListener('blur', () => hide());
|
|
950
|
+
input.addEventListener('keydown', (e) => {
|
|
951
|
+
if (e.key === 'ArrowDown' && items.length) {
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
activeIndex = (activeIndex + 1) % items.length;
|
|
954
|
+
renderActive();
|
|
955
|
+
} else if (e.key === 'ArrowUp' && items.length) {
|
|
956
|
+
e.preventDefault();
|
|
957
|
+
activeIndex = (activeIndex - 1 + items.length) % items.length;
|
|
958
|
+
renderActive();
|
|
959
|
+
} else if (e.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
|
|
960
|
+
e.preventDefault();
|
|
961
|
+
select(items[activeIndex]);
|
|
962
|
+
} else if (e.key === 'Escape') {
|
|
963
|
+
hide();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
832
968
|
// One name+lat+lon(+optional "use my position") row, shared by start
|
|
833
|
-
// line, finish line, and mark entry.
|
|
969
|
+
// line, finish line, and mark entry. The name field autocompletes against
|
|
970
|
+
// existing SignalK waypoints (e.g. ones already placed on a chart
|
|
971
|
+
// plotter) — picking one fills in lat/lon (and the name) from it, so a
|
|
972
|
+
// start/mark/finish position doesn't have to be typed by hand if a
|
|
973
|
+
// waypoint for it already exists.
|
|
834
974
|
function buildPointRow(point) {
|
|
835
975
|
const row = document.createElement('div');
|
|
836
976
|
row.className = 'course-point-row';
|
|
837
977
|
|
|
978
|
+
const nameWrap = document.createElement('div');
|
|
979
|
+
nameWrap.className = 'autocomplete course-name-autocomplete';
|
|
980
|
+
|
|
838
981
|
const nameInput = document.createElement('input');
|
|
839
982
|
nameInput.type = 'text';
|
|
840
983
|
nameInput.className = 'course-name-input';
|
|
841
984
|
nameInput.placeholder = 'Name (optional)';
|
|
985
|
+
nameInput.autocomplete = 'off';
|
|
842
986
|
nameInput.value = (point && point.name) || '';
|
|
843
987
|
|
|
988
|
+
const nameSuggestions = document.createElement('div');
|
|
989
|
+
nameSuggestions.className = 'suggestions';
|
|
990
|
+
nameSuggestions.hidden = true;
|
|
991
|
+
nameWrap.append(nameInput, nameSuggestions);
|
|
992
|
+
|
|
844
993
|
const latInput = document.createElement('input');
|
|
845
994
|
latInput.type = 'number';
|
|
846
995
|
latInput.step = 'any';
|
|
@@ -869,7 +1018,18 @@
|
|
|
869
1018
|
}
|
|
870
1019
|
});
|
|
871
1020
|
|
|
872
|
-
|
|
1021
|
+
attachAutocomplete(
|
|
1022
|
+
nameInput,
|
|
1023
|
+
nameSuggestions,
|
|
1024
|
+
() => waypoints,
|
|
1025
|
+
(wp) => {
|
|
1026
|
+
nameInput.value = wp.name;
|
|
1027
|
+
latInput.value = wp.lat;
|
|
1028
|
+
lonInput.value = wp.lon;
|
|
1029
|
+
}
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
row.append(nameWrap, latInput, lonInput, useHereBtn);
|
|
873
1033
|
return { row, nameInput, latInput, lonInput };
|
|
874
1034
|
}
|
|
875
1035
|
|
|
@@ -1220,15 +1380,50 @@
|
|
|
1220
1380
|
});
|
|
1221
1381
|
const cutoff = replayLive ? Infinity : replayTime;
|
|
1222
1382
|
|
|
1383
|
+
// A boat's track is a series of discrete observations (an AIS sample
|
|
1384
|
+
// roughly every 15s, or a single point at a manually-recorded mark
|
|
1385
|
+
// rounding) — the path between them is drawn as a straight line, but
|
|
1386
|
+
// that's an assumption, not a fact. Two things make that assumption
|
|
1387
|
+
// visible instead of silently implied: a small dot at every actual
|
|
1388
|
+
// observation, and — for the moving "current position" marker — linear
|
|
1389
|
+
// interpolation between the two observations straddling the replay
|
|
1390
|
+
// time, drawn as a hollow ring instead of a solid dot so it reads as
|
|
1391
|
+
// estimated, not observed. Only interpolated across a reasonably tight
|
|
1392
|
+
// gap; beyond that (AIS dropped out, or a mark rounding recorded far
|
|
1393
|
+
// from any real fix) a straight line would just fabricate a plausible-
|
|
1394
|
+
// looking but likely wrong path, so it falls back to the last real
|
|
1395
|
+
// observation instead, same as before this existed.
|
|
1396
|
+
const MAX_INTERP_GAP_MS = 5 * 60 * 1000;
|
|
1397
|
+
|
|
1223
1398
|
boatsWithTrack.forEach((b, idx) => {
|
|
1224
1399
|
const color = CHART_PALETTE[idx % CHART_PALETTE.length];
|
|
1225
1400
|
const pts = b.track.filter((pt) => pt.t <= cutoff);
|
|
1226
1401
|
if (!pts.length) return;
|
|
1227
1402
|
const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
|
|
1228
1403
|
parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85" />`);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1404
|
+
pts.forEach((pt) => {
|
|
1405
|
+
const p = proj(pt);
|
|
1406
|
+
parts.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2" fill="${color}" opacity="0.7" />`);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
const prev = pts[pts.length - 1];
|
|
1410
|
+
let current = prev;
|
|
1411
|
+
let interpolated = false;
|
|
1412
|
+
if (!replayLive && prev.t < cutoff) {
|
|
1413
|
+
const next = b.track.find((pt) => pt.t > cutoff);
|
|
1414
|
+
if (next && next.t - prev.t <= MAX_INTERP_GAP_MS) {
|
|
1415
|
+
const frac = (cutoff - prev.t) / (next.t - prev.t);
|
|
1416
|
+
current = { lat: prev.lat + (next.lat - prev.lat) * frac, lon: prev.lon + (next.lon - prev.lon) * frac };
|
|
1417
|
+
interpolated = true;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const cur = proj(current);
|
|
1421
|
+
if (interpolated) {
|
|
1422
|
+
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" />`);
|
|
1423
|
+
} else {
|
|
1424
|
+
parts.push(`<circle cx="${cur.x}" cy="${cur.y}" r="4" fill="${color}" />`);
|
|
1425
|
+
}
|
|
1426
|
+
parts.push(`<text x="${(cur.x + 7).toFixed(1)}" y="${(cur.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
|
|
1232
1427
|
});
|
|
1233
1428
|
|
|
1234
1429
|
courseChart.innerHTML = parts.join('');
|
|
@@ -1278,6 +1473,8 @@
|
|
|
1278
1473
|
finishTime: b.finishTime,
|
|
1279
1474
|
dnf: !!b.dnf,
|
|
1280
1475
|
dnfPosition: b.dnfPosition || null,
|
|
1476
|
+
markTimes: b.markTimes || {},
|
|
1477
|
+
roundedMarksCount: b.roundedMarksCount || 0,
|
|
1281
1478
|
elapsedMs,
|
|
1282
1479
|
correctedMs,
|
|
1283
1480
|
estimate,
|
|
@@ -1288,6 +1485,13 @@
|
|
|
1288
1485
|
if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
|
|
1289
1486
|
if (a.rankMs == null) return 1;
|
|
1290
1487
|
if (b.rankMs == null) return -1;
|
|
1488
|
+
// Mirrors the server's rankedBoatList: with no AIS-based ETA to
|
|
1489
|
+
// fall back on, a boat recorded further around the course (by
|
|
1490
|
+
// either automatic AIS detection or a manually recorded rounding)
|
|
1491
|
+
// ranks ahead regardless of corrected time so far.
|
|
1492
|
+
if (!a.finishTime && !b.finishTime && !a.estimate && !b.estimate && a.roundedMarksCount !== b.roundedMarksCount) {
|
|
1493
|
+
return b.roundedMarksCount - a.roundedMarksCount;
|
|
1494
|
+
}
|
|
1291
1495
|
return a.rankMs - b.rankMs;
|
|
1292
1496
|
});
|
|
1293
1497
|
}
|
|
@@ -1465,6 +1669,15 @@
|
|
|
1465
1669
|
|
|
1466
1670
|
const tdElapsed = document.createElement('td');
|
|
1467
1671
|
const tdCorrected = document.createElement('td');
|
|
1672
|
+
// One small pill button per course mark, in rounding order — click an
|
|
1673
|
+
// unrounded one to record "rounded now", click a rounded one to clear
|
|
1674
|
+
// it. Rebuilt only when the course's marks actually change (see
|
|
1675
|
+
// render()), not every tick. Independent of the automatic AIS
|
|
1676
|
+
// track-based rounding detection; either one counts (see the server's
|
|
1677
|
+
// countRoundedMarks) — this is the manual alternative, for a boat with
|
|
1678
|
+
// no MMSI/AIS at all or to correct a rounding the detection missed.
|
|
1679
|
+
const tdMarks = document.createElement('td');
|
|
1680
|
+
tdMarks.className = 'marks-cell';
|
|
1468
1681
|
const tdEstFinish = document.createElement('td');
|
|
1469
1682
|
tdEstFinish.className = 'est-finish';
|
|
1470
1683
|
const tdVsSelf = document.createElement('td');
|
|
@@ -1539,12 +1752,15 @@
|
|
|
1539
1752
|
const tdRemove = document.createElement('td');
|
|
1540
1753
|
tdRemove.appendChild(removeBtn);
|
|
1541
1754
|
|
|
1542
|
-
tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
|
|
1755
|
+
tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdMarks, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
|
|
1543
1756
|
|
|
1544
1757
|
return {
|
|
1545
1758
|
tr,
|
|
1546
1759
|
selfBtn,
|
|
1547
1760
|
nameSpan,
|
|
1761
|
+
tdMarks,
|
|
1762
|
+
marksSignature: undefined,
|
|
1763
|
+
marksPills: [],
|
|
1548
1764
|
tdEstFinish,
|
|
1549
1765
|
tdVsSelf,
|
|
1550
1766
|
sailNumberInput,
|
|
@@ -1744,6 +1960,33 @@
|
|
|
1744
1960
|
row.tdElapsed.textContent = fmtDuration(b.elapsedMs);
|
|
1745
1961
|
row.tdCorrected.textContent = fmtDuration(b.correctedMs);
|
|
1746
1962
|
|
|
1963
|
+
const courseMarks = (raceState.course && raceState.course.marks) || [];
|
|
1964
|
+
const marksSig = courseMarks.map((m) => m.id).join(',');
|
|
1965
|
+
if (row.marksSignature !== marksSig) {
|
|
1966
|
+
row.tdMarks.innerHTML = '';
|
|
1967
|
+
row.marksPills = courseMarks.map((m, i) => {
|
|
1968
|
+
const pill = document.createElement('button');
|
|
1969
|
+
pill.type = 'button';
|
|
1970
|
+
pill.className = 'mark-pill';
|
|
1971
|
+
pill.textContent = String(i + 1);
|
|
1972
|
+
pill.addEventListener('click', () => {
|
|
1973
|
+
const boat = raceState.boats[b.boatId];
|
|
1974
|
+
const already = boat && boat.markTimes && boat.markTimes[m.id] != null;
|
|
1975
|
+
setMarkTime(b.boatId, m.id, already ? null : Date.now());
|
|
1976
|
+
});
|
|
1977
|
+
row.tdMarks.appendChild(pill);
|
|
1978
|
+
return pill;
|
|
1979
|
+
});
|
|
1980
|
+
row.marksSignature = marksSig;
|
|
1981
|
+
}
|
|
1982
|
+
courseMarks.forEach((m, i) => {
|
|
1983
|
+
const pill = row.marksPills[i];
|
|
1984
|
+
const t = b.markTimes && b.markTimes[m.id];
|
|
1985
|
+
pill.classList.toggle('rounded', t != null);
|
|
1986
|
+
const label = m.name || `Mark ${i + 1}`;
|
|
1987
|
+
pill.title = t != null ? `${label} — rounded ${new Date(t).toLocaleTimeString()} (click to clear)` : `${label} — click to mark rounded now`;
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1747
1990
|
if (b.dnf) {
|
|
1748
1991
|
row.tdEstFinish.textContent = 'DNF';
|
|
1749
1992
|
} else if (b.finishTime) {
|
|
@@ -1891,13 +2134,67 @@
|
|
|
1891
2134
|
renderMarkRows();
|
|
1892
2135
|
});
|
|
1893
2136
|
saveCourseBtn.addEventListener('click', saveCourse);
|
|
2137
|
+
|
|
2138
|
+
// Runs the replay forward at replaySpeed simulated seconds per real
|
|
2139
|
+
// second, on its own timer (independent of the general 1s render tick)
|
|
2140
|
+
// so playback still looks reasonably smooth at low speeds. Stops itself
|
|
2141
|
+
// once it reaches the latest recorded position — there's nothing to play
|
|
2142
|
+
// into beyond that until more track is recorded.
|
|
2143
|
+
function stopReplayPlayback() {
|
|
2144
|
+
replayPlaying = false;
|
|
2145
|
+
replayPlayBtn.textContent = '▶ Play';
|
|
2146
|
+
if (replayPlayTimer) {
|
|
2147
|
+
clearInterval(replayPlayTimer);
|
|
2148
|
+
replayPlayTimer = null;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
function replayTick() {
|
|
2152
|
+
const now = Date.now();
|
|
2153
|
+
const elapsedMs = now - replayPlayLastTick;
|
|
2154
|
+
replayPlayLastTick = now;
|
|
2155
|
+
const maxT = Number(replaySlider.max);
|
|
2156
|
+
if (!isFinite(maxT)) {
|
|
2157
|
+
stopReplayPlayback();
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
replayTime = Math.min(replayTime + elapsedMs * replaySpeed, maxT);
|
|
2161
|
+
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
2162
|
+
if (replayTime >= maxT) stopReplayPlayback();
|
|
2163
|
+
renderChart();
|
|
2164
|
+
}
|
|
2165
|
+
function startReplayPlayback() {
|
|
2166
|
+
if (replayPlaying) return;
|
|
2167
|
+
// Nothing to play forward into from Live — start over from the
|
|
2168
|
+
// earliest recorded position instead.
|
|
2169
|
+
if (replayLive) {
|
|
2170
|
+
const minT = Number(replaySlider.min);
|
|
2171
|
+
if (isFinite(minT)) replayTime = minT;
|
|
2172
|
+
}
|
|
2173
|
+
replayLive = false;
|
|
2174
|
+
replayPlaying = true;
|
|
2175
|
+
replayPlayBtn.textContent = '⏸ Pause';
|
|
2176
|
+
replayPlayLastTick = Date.now();
|
|
2177
|
+
replayPlayTimer = setInterval(replayTick, 200);
|
|
2178
|
+
renderChart();
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1894
2181
|
replaySlider.addEventListener('input', () => {
|
|
2182
|
+
stopReplayPlayback();
|
|
1895
2183
|
replayLive = false;
|
|
1896
2184
|
replayTime = Number(replaySlider.value);
|
|
1897
2185
|
replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
|
|
1898
2186
|
renderChart();
|
|
1899
2187
|
});
|
|
2188
|
+
replayPlayBtn.addEventListener('click', () => {
|
|
2189
|
+
if (replayPlaying) stopReplayPlayback();
|
|
2190
|
+
else startReplayPlayback();
|
|
2191
|
+
});
|
|
2192
|
+
replaySpeedSlider.addEventListener('input', () => {
|
|
2193
|
+
replaySpeed = Number(replaySpeedSlider.value);
|
|
2194
|
+
replaySpeedLabel.textContent = replaySpeed + 'x';
|
|
2195
|
+
});
|
|
1900
2196
|
replayLiveBtn.addEventListener('click', () => {
|
|
2197
|
+
stopReplayPlayback();
|
|
1901
2198
|
replayLive = true;
|
|
1902
2199
|
renderChart();
|
|
1903
2200
|
});
|
|
@@ -1905,7 +2202,7 @@
|
|
|
1905
2202
|
async function init() {
|
|
1906
2203
|
await loadVetEnabled();
|
|
1907
2204
|
await loadRaceImportEnabled();
|
|
1908
|
-
await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
|
|
2205
|
+
await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadWaypoints(), loadRacesList()]);
|
|
1909
2206
|
await loadRaceState();
|
|
1910
2207
|
render();
|
|
1911
2208
|
setInterval(render, 1000);
|
package/public/index.html
CHANGED
|
@@ -86,6 +86,9 @@
|
|
|
86
86
|
<div id="replayControls" class="replay-controls" hidden>
|
|
87
87
|
<input type="range" id="replaySlider" min="0" max="100" value="100" />
|
|
88
88
|
<span id="replayTimeLabel">Live</span>
|
|
89
|
+
<button id="replayPlayBtn" type="button" class="secondary">▶ Play</button>
|
|
90
|
+
<input type="range" id="replaySpeedSlider" class="replay-speed-slider" min="1" max="60" value="10" title="Playback speed" />
|
|
91
|
+
<span id="replaySpeedLabel" class="replay-speed-label">10x</span>
|
|
89
92
|
<button id="replayLiveBtn" type="button" class="secondary">Live</button>
|
|
90
93
|
</div>
|
|
91
94
|
</div>
|
|
@@ -127,6 +130,7 @@
|
|
|
127
130
|
<th>Start time</th>
|
|
128
131
|
<th>Elapsed</th>
|
|
129
132
|
<th>Corrected</th>
|
|
133
|
+
<th>Marks</th>
|
|
130
134
|
<th>Est. finish</th>
|
|
131
135
|
<th>vs Self</th>
|
|
132
136
|
<th>Finish time</th>
|
package/public/style.css
CHANGED
|
@@ -257,8 +257,13 @@ main {
|
|
|
257
257
|
align-items: center;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
.course-point-row .course-name-
|
|
260
|
+
.course-point-row .course-name-autocomplete {
|
|
261
|
+
position: relative;
|
|
261
262
|
width: 7rem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.course-point-row .course-name-input {
|
|
266
|
+
width: 100%;
|
|
262
267
|
padding: 0.3rem 0.4rem;
|
|
263
268
|
background: var(--panel);
|
|
264
269
|
color: var(--text);
|
|
@@ -364,6 +369,7 @@ main {
|
|
|
364
369
|
.replay-controls {
|
|
365
370
|
margin-top: 0.5rem;
|
|
366
371
|
display: flex;
|
|
372
|
+
flex-wrap: wrap;
|
|
367
373
|
align-items: center;
|
|
368
374
|
gap: 0.6rem;
|
|
369
375
|
max-width: 800px;
|
|
@@ -373,6 +379,11 @@ main {
|
|
|
373
379
|
flex: 1;
|
|
374
380
|
}
|
|
375
381
|
|
|
382
|
+
.replay-controls input[type='range'].replay-speed-slider {
|
|
383
|
+
flex: none;
|
|
384
|
+
width: 5rem;
|
|
385
|
+
}
|
|
386
|
+
|
|
376
387
|
.replay-controls span {
|
|
377
388
|
font-size: 0.8rem;
|
|
378
389
|
color: var(--muted);
|
|
@@ -380,6 +391,37 @@ main {
|
|
|
380
391
|
min-width: 5rem;
|
|
381
392
|
}
|
|
382
393
|
|
|
394
|
+
.replay-controls span.replay-speed-label {
|
|
395
|
+
min-width: 2.2rem;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.marks-cell {
|
|
399
|
+
display: flex;
|
|
400
|
+
flex-wrap: wrap;
|
|
401
|
+
gap: 0.25rem;
|
|
402
|
+
min-width: 6rem;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.mark-pill {
|
|
406
|
+
width: 1.6rem;
|
|
407
|
+
height: 1.6rem;
|
|
408
|
+
padding: 0;
|
|
409
|
+
border-radius: 50%;
|
|
410
|
+
border: 1px solid var(--border);
|
|
411
|
+
background: transparent;
|
|
412
|
+
color: var(--muted);
|
|
413
|
+
font-size: 0.7rem;
|
|
414
|
+
font-weight: 700;
|
|
415
|
+
cursor: pointer;
|
|
416
|
+
line-height: 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.mark-pill.rounded {
|
|
420
|
+
background: var(--accent);
|
|
421
|
+
border-color: var(--accent);
|
|
422
|
+
color: #04202e;
|
|
423
|
+
}
|
|
424
|
+
|
|
383
425
|
.est-finish {
|
|
384
426
|
font-size: 0.85rem;
|
|
385
427
|
color: var(--muted);
|