signalk-pypilot-newui 3.4.0 → 3.5.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/CHANGELOG.md +64 -0
- package/README.md +7 -4
- package/dist/autopilot-provider.js +60 -7
- package/dist/index.js +51 -2
- package/dist/maneuver-trace.js +32 -0
- package/dist/steer-watch.js +31 -4
- package/dist/trip-recorder.js +29 -12
- package/dist/virtual-tack.js +10 -0
- package/package.json +1 -1
- package/public/app.js +234 -31
- package/public/index.html +3 -3
- package/public/style.css +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.0 — 2026-10-19 — Fixes from two sea trials, logbook while sailing
|
|
4
|
+
|
|
5
|
+
Rolls up Rev509 → Rev516 on top of 3.4.0. Minor bump: live logbook,
|
|
6
|
+
heading in the compass tack summary, trace pruning. No breaking changes.
|
|
7
|
+
|
|
8
|
+
Rev515 comes from the sail of 2026-10-19: it reverts two changes that
|
|
9
|
+
made things worse (Rev510 compass-until-6°, Rev512 head-to-wind in
|
|
10
|
+
compass) and fixes what the sail showed. Audit:
|
|
11
|
+
`docs/_audit_sail_2026-10-19.md`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **Maneuver traces keep the newest 60 files** (pruned when a new trace
|
|
15
|
+
opens). 201 files / 888 MB had piled up on the Tunatunes.
|
|
16
|
+
- **Stuck pilot recovery only on a real false end stop**: it now needs
|
|
17
|
+
pypilot's directional overcurrent fault. 0 A is not evidence (the
|
|
18
|
+
TinyPilot reads 0 A while driving, `DRIVER_TIMEOUT`), it never acts
|
|
19
|
+
during pypilot's own tack or for 20 s after, and a target change of
|
|
20
|
+
more than 10° restarts its clock. On 2026-10-19 it cycled the AP three
|
|
21
|
+
times by mistake, twice in the middle of a tack from the remote.
|
|
22
|
+
- **Gybe confirmation only right after a cancel**: the long-way gybe
|
|
23
|
+
question (> 180°) now appears only within 20 s of cancelling a tack -
|
|
24
|
+
the 2026-10-18 accident - not on deliberate gybes. Yes / No buttons
|
|
25
|
+
are translated.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **Logbook while sailing**: the trip being recorded appears in the
|
|
29
|
+
logbook with an "IN PROGRESS" badge, live statistics (distance, time,
|
|
30
|
+
speeds, wind, tacks, energy...) and the track on the chart up to now.
|
|
31
|
+
- **Tack summary in compass mode** shows the mean heading before / after
|
|
32
|
+
instead of the apparent wind angle.
|
|
33
|
+
- **Confirmation before a gybe the long way** (see above).
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **Cancelling a virtual tack left a "ghost" target**: pypilot processes
|
|
37
|
+
the mode change in its own loop and overwrote our "stay here" target
|
|
38
|
+
with one recomputed from the compass error (2026-10-19: 56° -> 3.9°,
|
|
39
|
+
bow into the wind). The cancel now waits for the mode change, writes
|
|
40
|
+
the target, checks it and rewrites it if pypilot moved it.
|
|
41
|
+
- **Remote tack HUD turning the wrong way**: pypilot overwrites
|
|
42
|
+
`ap.tack.direction` with the wind side ~0.5 s after the remote starts a
|
|
43
|
+
tack; the HUD now keeps the direction it had at the start. It also stays
|
|
44
|
+
open until the boat is within 10° of the new target (max 15 s), not only
|
|
45
|
+
until pypilot's 50 % threshold.
|
|
46
|
+
- **Tack countdown**: shows the distance from the wind arrow to the target
|
|
47
|
+
diamond along the turn (what the rose shows), no "ajustando" text.
|
|
48
|
+
- **Two screens showing different modes after a tack**: the mode now
|
|
49
|
+
comes from the backend (plain mode label, no "TACKING ->" prefix).
|
|
50
|
+
- **Previous manoeuvre's summary popping up during the next one**.
|
|
51
|
+
- **Settings tab**: dragging a slider no longer darkens the whole tab
|
|
52
|
+
(only the other rows), and the darkening clears if the touch is
|
|
53
|
+
interrupted. A value the sailor writes stays on screen until the
|
|
54
|
+
backend confirms it (or reports a different one).
|
|
55
|
+
- **`ap.tack.direction` noise in standby**: published as `none` once
|
|
56
|
+
while the AP is off (pypilot flips it ~40 times a minute there).
|
|
57
|
+
- **Gust ghost arrow almost never visible**: the fade started in the
|
|
58
|
+
same frame the ghost was created, so the browser skipped it.
|
|
59
|
+
|
|
60
|
+
### Reverted
|
|
61
|
+
- Rev510 "virtual tack holds compass until 6° of the final heading": the
|
|
62
|
+
apparent wind at the final heading is not the mirror, so wind mode then
|
|
63
|
+
swung back 40-50° (S-turn); handover is at 20° again.
|
|
64
|
+
- Rev512 "head to wind holds in compass": head to wind is always in
|
|
65
|
+
apparent wind, as before.
|
|
66
|
+
|
|
3
67
|
## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
|
|
4
68
|
|
|
5
69
|
Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
|
package/README.md
CHANGED
|
@@ -149,10 +149,11 @@ up automatically.
|
|
|
149
149
|
17. **Auto-disengage on lost authority** — RMS heading error > 30°
|
|
150
150
|
with servo duty > 90% sustained 10 s. **Stuck pilot recovery**
|
|
151
151
|
(opt-in): with no rudder sensor, pypilot treats an overcurrent as
|
|
152
|
-
the end of rudder travel and stops driving that way; when
|
|
153
|
-
|
|
154
|
-
off and on (same mode and target)
|
|
155
|
-
minute.
|
|
152
|
+
the end of rudder travel and stops driving that way; when pypilot
|
|
153
|
+
reports that directional overcurrent fault and the error keeps
|
|
154
|
+
growing, the plugin turns the AP off and on (same mode and target)
|
|
155
|
+
to clear it, at most 3 times per minute. Never during pypilot's own
|
|
156
|
+
tack.
|
|
156
157
|
18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
|
|
157
158
|
DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
|
|
158
159
|
and link signal.
|
|
@@ -200,6 +201,8 @@ up automatically.
|
|
|
200
201
|
split, min voltage, AP-engaged %), OpenSeaMap trace coloured by
|
|
201
202
|
SOG with markers on peak gust, peak SOG, min voltage and every
|
|
202
203
|
detected tack. Share as portrait PNG report card or as plain text.
|
|
204
|
+
The trip in progress can be opened while sailing (live statistics
|
|
205
|
+
and the track so far).
|
|
203
206
|
27. **Chart tab** — 9 live Trip Stats cards, history with
|
|
204
207
|
30 s / 2 min / 10 min windows and hover-freeze, plus a Servo
|
|
205
208
|
Health card.
|
|
@@ -26,6 +26,7 @@ const TARGET_PENDING_MS = 2500;
|
|
|
26
26
|
// value the user had just replaced. 0.1° covers float32 → double
|
|
27
27
|
// rounding on the wire without accidentally consuming a stale echo.
|
|
28
28
|
const TARGET_ECHO_TOL_RAD = 0.1 * DEG_TO_RAD;
|
|
29
|
+
const LONG_JIBE_CONFIRM_AFTER_CANCEL_MS = 20000;
|
|
29
30
|
// Rev272 (audit R07): bounded-cost normalisation. The previous while
|
|
30
31
|
// loop could spin forever on pathological inputs (e.g. |a-b| >= 1e15
|
|
31
32
|
// where 2π is below the mantissa's precision, so subtracting a full
|
|
@@ -113,6 +114,8 @@ class AutopilotProvider {
|
|
|
113
114
|
/** Rev506: recovery cycles (steer-recovery) toggle the AP off/on on
|
|
114
115
|
* purpose; that must NOT annul the tack the cycle is trying to save. */
|
|
115
116
|
this._recoveryCycleUntil = 0;
|
|
117
|
+
/** Rev515: last cancel of a running virtual tack (any source). */
|
|
118
|
+
this._lastVtCancelTs = 0;
|
|
116
119
|
if (opts?.allowDodge)
|
|
117
120
|
this.allowDodge = true;
|
|
118
121
|
this.onDataChanged = opts?.onDataChanged;
|
|
@@ -766,6 +769,24 @@ class AutopilotProvider {
|
|
|
766
769
|
&& Number.isFinite(opts.finalAngleRadOverride)
|
|
767
770
|
&& Math.abs(opts.finalAngleRadOverride) <= Math.PI
|
|
768
771
|
? opts.finalAngleRadOverride : null;
|
|
772
|
+
// Rev511: same geometry the FSM will compute; refuse a long-way gybe
|
|
773
|
+
// unless the sailor confirmed it. Rev515 (Carlos 2026-10-19: 7 asks
|
|
774
|
+
// in a sail of deliberate gybes): only right after a cancel, which is
|
|
775
|
+
// the 2026-10-18 18:11:44 accident (second tap 3 s after cancelling).
|
|
776
|
+
if (!opts.confirmLongJibe && Date.now() - this._lastVtCancelTs < LONG_JIBE_CONFIRM_AFTER_CANCEL_MS) {
|
|
777
|
+
const g = (0, virtual_tack_1.computeTackGeometry)({
|
|
778
|
+
angleStartRad: awaRad,
|
|
779
|
+
hStartRad: headingRad,
|
|
780
|
+
direction: opts.direction,
|
|
781
|
+
angleNewRadOverride: finalAngleOverride ?? undefined,
|
|
782
|
+
});
|
|
783
|
+
if ((0, virtual_tack_1.needsLongJibeConfirm)(g)) {
|
|
784
|
+
const err = new Error(`virtual-tack: gybe the long way (${(g.deltaHRad * RAD_TO_DEG).toFixed(0)} deg) needs confirmation`);
|
|
785
|
+
err.code = "confirm_long_jibe";
|
|
786
|
+
err.deltaDeg = g.deltaHRad * RAD_TO_DEG;
|
|
787
|
+
throw err;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
769
790
|
this._runVirtualTack(opts.direction, windMode, headingRad, awaRad, finalAngleOverride).catch((e) => {
|
|
770
791
|
// eslint-disable-next-line no-console
|
|
771
792
|
console.log(`[startVirtualTack] driver threw: ${e?.message || e}`);
|
|
@@ -1302,13 +1323,32 @@ class AutopilotProvider {
|
|
|
1302
1323
|
}
|
|
1303
1324
|
catch { /* noop */ }
|
|
1304
1325
|
if (awaRad !== null) {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1326
|
+
// Rev515 (sail 2026-10-19 19:08:24, 19:15:44, 19:17:10): pypilot
|
|
1327
|
+
// processes the mode change in its own loop and "keeps the
|
|
1328
|
+
// heading" by recomputing heading_command from the compass error
|
|
1329
|
+
// - AFTER our target had landed, so it overwrote it (56 -> 3.9,
|
|
1330
|
+
// -57 -> 4.8: bow into the wind, "target fantasma"). Wait for
|
|
1331
|
+
// the mode echo plus a few pypilot loops, write, check, rewrite.
|
|
1332
|
+
await this._waitValue("ap.mode", windMode, 1500);
|
|
1333
|
+
await sleep(400);
|
|
1334
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1335
|
+
this._vtInternalWrite = true;
|
|
1336
|
+
try {
|
|
1337
|
+
await this.setTarget(awaRad);
|
|
1338
|
+
}
|
|
1339
|
+
catch { /* best-effort */ }
|
|
1340
|
+
finally {
|
|
1341
|
+
this._vtInternalWrite = false;
|
|
1342
|
+
}
|
|
1343
|
+
await sleep(1200);
|
|
1344
|
+
const cmdDeg = Number((this.client.getValues?.() || {})["ap.heading_command"]);
|
|
1345
|
+
if (!Number.isFinite(cmdDeg))
|
|
1346
|
+
break;
|
|
1347
|
+
const diff = Math.abs(((cmdDeg - awaRad * RAD_TO_DEG) % 360 + 540) % 360 - 180);
|
|
1348
|
+
if (diff <= 2)
|
|
1349
|
+
break;
|
|
1350
|
+
// eslint-disable-next-line no-console
|
|
1351
|
+
console.log(`[virtual-tack ${vtId}] cancel: pypilot moved the target to ${cmdDeg.toFixed(1)}deg, rewriting ${(awaRad * RAD_TO_DEG).toFixed(1)}deg`);
|
|
1312
1352
|
}
|
|
1313
1353
|
}
|
|
1314
1354
|
}
|
|
@@ -1361,6 +1401,7 @@ class AutopilotProvider {
|
|
|
1361
1401
|
return;
|
|
1362
1402
|
// eslint-disable-next-line no-console
|
|
1363
1403
|
console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase} (${reason})`);
|
|
1404
|
+
this._lastVtCancelTs = Date.now();
|
|
1364
1405
|
this._virtualTack.phase = "cancelling";
|
|
1365
1406
|
this._virtualTack.outcomeReason = reason;
|
|
1366
1407
|
this.notifyChanged("all");
|
|
@@ -1382,6 +1423,18 @@ class AutopilotProvider {
|
|
|
1382
1423
|
this.notifyChanged("virtualTack");
|
|
1383
1424
|
return true;
|
|
1384
1425
|
}
|
|
1426
|
+
/** Rev515: wait until pypilot reports key === expected (no cancel check;
|
|
1427
|
+
* used by the cancel path itself). */
|
|
1428
|
+
async _waitValue(key, expected, timeoutMs) {
|
|
1429
|
+
const t0 = Date.now();
|
|
1430
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
1431
|
+
const v = this.client.getValues?.() || {};
|
|
1432
|
+
if (v[key] === expected)
|
|
1433
|
+
return true;
|
|
1434
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1435
|
+
}
|
|
1436
|
+
return false;
|
|
1437
|
+
}
|
|
1385
1438
|
/** Rev506: true while a virtual tack is running (not terminal). */
|
|
1386
1439
|
isVirtualTackActive() {
|
|
1387
1440
|
const vt = this._virtualTack;
|
package/dist/index.js
CHANGED
|
@@ -32,7 +32,7 @@ const trip_recorder_1 = require("./trip-recorder");
|
|
|
32
32
|
const maneuver_trace_1 = require("./maneuver-trace");
|
|
33
33
|
// Rev counter bumped on every build so the user can distinguish deploys
|
|
34
34
|
// from the webapp header (feedback_revision_bump_each_build).
|
|
35
|
-
const PLUGIN_REVISION = "
|
|
35
|
+
const PLUGIN_REVISION = "Rev516";
|
|
36
36
|
// Rev59: read package.json once at load time so /status can report the
|
|
37
37
|
// npm package version alongside the internal Rev counter.
|
|
38
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -523,7 +523,12 @@ module.exports = function (app) {
|
|
|
523
523
|
publishCatalogDerived(catalog);
|
|
524
524
|
});
|
|
525
525
|
client.on("value", (name, value) => {
|
|
526
|
-
|
|
526
|
+
// Rev513: ap.tack.direction is meaningless in standby and pypilot
|
|
527
|
+
// flips it none <-> port/starboard ~40 times a minute there.
|
|
528
|
+
if (name === "ap.tack.direction")
|
|
529
|
+
_publishTackDirection(value);
|
|
530
|
+
else
|
|
531
|
+
publishValue(name, value);
|
|
527
532
|
if (apProvider) {
|
|
528
533
|
// Rev352: receiveValue now returns the field that changed
|
|
529
534
|
// (engaged | target | all | null) so we push field-scoped
|
|
@@ -535,6 +540,14 @@ module.exports = function (app) {
|
|
|
535
540
|
if (changedField)
|
|
536
541
|
pushAutopilotUpdate(changedField);
|
|
537
542
|
}
|
|
543
|
+
if (name === "ap.enabled") {
|
|
544
|
+
// Rev513: re-sync the direction on engage / disengage (after the
|
|
545
|
+
// provider has taken the new engaged state).
|
|
546
|
+
try {
|
|
547
|
+
_publishTackDirection(client?.getValues?.()["ap.tack.direction"]);
|
|
548
|
+
}
|
|
549
|
+
catch { /* silent */ }
|
|
550
|
+
}
|
|
538
551
|
// Rev299 (I2): watch ap.tack.state for the tacking → none
|
|
539
552
|
// transition, which is the moment the sailor needs the
|
|
540
553
|
// catch-up nudge. `ap.tack.direction` is watched separately
|
|
@@ -2044,10 +2057,16 @@ module.exports = function (app) {
|
|
|
2044
2057
|
direction: dir,
|
|
2045
2058
|
requestId,
|
|
2046
2059
|
finalAngleRadOverride,
|
|
2060
|
+
confirmLongJibe: req.body?.confirmLongJibe === true,
|
|
2047
2061
|
});
|
|
2048
2062
|
res.json(r);
|
|
2049
2063
|
}
|
|
2050
2064
|
catch (e) {
|
|
2065
|
+
// Rev511: 428 (not 409, which the visor retries) so the visor
|
|
2066
|
+
// asks the sailor before a long-way gybe.
|
|
2067
|
+
if (e?.code === "confirm_long_jibe") {
|
|
2068
|
+
return res.status(428).json({ error: e.message, code: e.code, deltaDeg: e.deltaDeg });
|
|
2069
|
+
}
|
|
2051
2070
|
res.status(409).json({ error: e?.message || String(e) });
|
|
2052
2071
|
}
|
|
2053
2072
|
});
|
|
@@ -3270,9 +3289,12 @@ module.exports = function (app) {
|
|
|
3270
3289
|
return res.status(503).json({ error: "recorder not initialised" });
|
|
3271
3290
|
// Return list + attach summary for each so the visor can render
|
|
3272
3291
|
// the card grid with one call.
|
|
3292
|
+
const liveId = tripRecorder.currentTripId();
|
|
3273
3293
|
const rows = tripRecorder.list().map((r) => ({
|
|
3274
3294
|
...r,
|
|
3275
3295
|
summary: tripRecorder.summary(r.id),
|
|
3296
|
+
// Rev516: the trip being recorded right now.
|
|
3297
|
+
live: r.id === liveId,
|
|
3276
3298
|
}));
|
|
3277
3299
|
res.json({ trips: rows });
|
|
3278
3300
|
});
|
|
@@ -3309,6 +3331,9 @@ module.exports = function (app) {
|
|
|
3309
3331
|
const id = String(req.params.id || "").replace(/[^A-Za-z0-9\-]/g, "");
|
|
3310
3332
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
3311
3333
|
const fs = require("fs");
|
|
3334
|
+
// Rev516: trip in progress -> write the pending samples first.
|
|
3335
|
+
if (id === tripRecorder.currentTripId())
|
|
3336
|
+
tripRecorder.flushNow();
|
|
3312
3337
|
const file = tripRecorder.jsonlPath(id);
|
|
3313
3338
|
if (!fs.existsSync(file))
|
|
3314
3339
|
return res.status(404).json({ error: "not found" });
|
|
@@ -3981,6 +4006,11 @@ module.exports = function (app) {
|
|
|
3981
4006
|
stepIndex: vt.stepIndex,
|
|
3982
4007
|
totalSteps: g?.intermediatesRad?.length ?? 0,
|
|
3983
4008
|
remainingDeg,
|
|
4009
|
+
// Rev510: whole compass rotation of this tack, sailor's +-1/+-10
|
|
4010
|
+
// included, so every visor can show "X deg of Y".
|
|
4011
|
+
totalDeg: (g && typeof g.deltaHRad === "number")
|
|
4012
|
+
? Math.abs(g.deltaHRad * RAD2DEG + (typeof vt.adjustRad === "number" ? vt.adjustRad * RAD2DEG : 0))
|
|
4013
|
+
: null,
|
|
3984
4014
|
outcomeReason: vt.outcomeReason ?? null,
|
|
3985
4015
|
},
|
|
3986
4016
|
});
|
|
@@ -4269,6 +4299,7 @@ module.exports = function (app) {
|
|
|
4269
4299
|
servoCurrentA: s.servoCurrent,
|
|
4270
4300
|
servoFlags: typeof pv["servo.flags"] === "string" ? pv["servo.flags"] : null,
|
|
4271
4301
|
nativeTack: !!(_tackStatePrev && _tackStatePrev !== "none"),
|
|
4302
|
+
cmdDeg: typeof pv["ap.heading_command"] === "number" ? pv["ap.heading_command"] : null,
|
|
4272
4303
|
recoveryEnabled: !!props.steerRecoveryEnabled && !!props.allowWrites && !!apProvider,
|
|
4273
4304
|
});
|
|
4274
4305
|
if (action !== "cycle" || !apProvider || _steerRecoveryInFlight)
|
|
@@ -5422,6 +5453,24 @@ module.exports = function (app) {
|
|
|
5422
5453
|
_applyWatches(c, catalog);
|
|
5423
5454
|
_startWatchSweep(c);
|
|
5424
5455
|
}
|
|
5456
|
+
// Rev513 (audit 2026-10-19): pypilot tacking.py sets ap.tack.direction
|
|
5457
|
+
// to 'none' on every loop while the AP is off, and its wind log sets
|
|
5458
|
+
// it back to port/starboard every 0.25 s when the wind holds on one
|
|
5459
|
+
// side. 2026-10-18: 7551 of 7587 changes were in standby (~40/min);
|
|
5460
|
+
// overnight at the berth 99.9 % of the maneuver-trace lines. In standby
|
|
5461
|
+
// we publish 'none' once; engaged, the real value as before.
|
|
5462
|
+
let _tackDirPublished = undefined;
|
|
5463
|
+
function _publishTackDirection(raw) {
|
|
5464
|
+
const pv = (client && client.connected) ? client.getValues() : {};
|
|
5465
|
+
const engaged = apProvider
|
|
5466
|
+
? !!apProvider.data.engaged
|
|
5467
|
+
: (pv["ap.enabled"] === true || pv["ap.enabled"] === 1);
|
|
5468
|
+
const v = engaged ? raw : "none";
|
|
5469
|
+
if (v === undefined || v === _tackDirPublished)
|
|
5470
|
+
return;
|
|
5471
|
+
_tackDirPublished = v;
|
|
5472
|
+
publishValue("ap.tack.direction", v);
|
|
5473
|
+
}
|
|
5425
5474
|
function publishValue(name, value) {
|
|
5426
5475
|
if (publisher_1.RESERVED_PYPILOT_KEYS.has(name))
|
|
5427
5476
|
return;
|
package/dist/maneuver-trace.js
CHANGED
|
@@ -101,6 +101,38 @@ class ManeuverTraceLog {
|
|
|
101
101
|
const pad = (n) => String(n).padStart(2, "0");
|
|
102
102
|
const name = `trace-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.jsonl`;
|
|
103
103
|
this.currentFile = path.join(this.dir, name);
|
|
104
|
+
try {
|
|
105
|
+
this.pruneToCount();
|
|
106
|
+
}
|
|
107
|
+
catch { /* silent */ }
|
|
108
|
+
}
|
|
109
|
+
/** Rev514 (Carlos 2026-10-19, "limita a 60"): keep only the newest
|
|
110
|
+
* `maxCount` trace files (201 files / 888 MB had piled up since
|
|
111
|
+
* 2026-09-28). Oldest first by name (trace-YYYYMMDD-HHMMSS), never the
|
|
112
|
+
* file in use. Same rule as SessionRecorder.pruneToCount (Rev505). */
|
|
113
|
+
pruneToCount(maxCount = 60) {
|
|
114
|
+
let deleted = 0;
|
|
115
|
+
let names = [];
|
|
116
|
+
try {
|
|
117
|
+
names = fs.readdirSync(this.dir).filter((f) => f.startsWith("trace-") && f.endsWith(".jsonl"));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return { deleted };
|
|
121
|
+
}
|
|
122
|
+
names.sort();
|
|
123
|
+
const current = this.currentFile ? path.basename(this.currentFile) : null;
|
|
124
|
+
const closed = names.filter((n) => n !== current);
|
|
125
|
+
const keepClosed = Math.max(0, maxCount - (current ? 1 : 0));
|
|
126
|
+
for (const name of closed.slice(0, Math.max(0, closed.length - keepClosed))) {
|
|
127
|
+
try {
|
|
128
|
+
fs.unlinkSync(path.join(this.dir, name));
|
|
129
|
+
deleted += 1;
|
|
130
|
+
}
|
|
131
|
+
catch { /* skip */ }
|
|
132
|
+
}
|
|
133
|
+
if (deleted > 0)
|
|
134
|
+
this.log("info", `[maneuver-trace] pruned ${deleted} old traces (keeping the newest ${maxCount})`);
|
|
135
|
+
return { deleted };
|
|
104
136
|
}
|
|
105
137
|
record(input, pre, getPost) {
|
|
106
138
|
const entry = {
|
package/dist/steer-watch.js
CHANGED
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Pure state machine: fed once per second, no I/O, no timers.
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.SteerWatch = exports.DROPPED_MIN_SOG_MS = exports.DROPPED_ALARM_MS = exports.CYCLE_MIN_GAP_MS = exports.CYCLE_WINDOW_MS = exports.MAX_CYCLES = exports.
|
|
14
|
+
exports.SteerWatch = exports.DROPPED_MIN_SOG_MS = exports.DROPPED_ALARM_MS = exports.CYCLE_MIN_GAP_MS = exports.CYCLE_WINDOW_MS = exports.MAX_CYCLES = exports.TARGET_CHANGE_RESET_DEG = exports.NATIVE_TACK_HOLDOFF_MS = exports.ENGAGE_GRACE_MS = exports.CONVERGE_MIN_DROP_DEG = exports.CONVERGE_WINDOW_MS = exports.STUCK_SUSTAIN_MS = exports.STUCK_ERR_DEG = void 0;
|
|
15
15
|
exports.hasDirectionalFault = hasDirectionalFault;
|
|
16
16
|
exports.STUCK_ERR_DEG = 25;
|
|
17
17
|
exports.STUCK_SUSTAIN_MS = 4000;
|
|
18
18
|
exports.CONVERGE_WINDOW_MS = 4000;
|
|
19
19
|
exports.CONVERGE_MIN_DROP_DEG = 3;
|
|
20
20
|
exports.ENGAGE_GRACE_MS = 5000;
|
|
21
|
-
|
|
21
|
+
// Rev515 (sail 2026-10-19): no current-based fallback. With the
|
|
22
|
+
// TinyPilot the current reads 0 A while the motor IS driving (servo.flags
|
|
23
|
+
// DRIVER_TIMEOUT), which fired three false cycles (18:53:32 and 18:54:14
|
|
24
|
+
// mid remote tack, 19:23:45 after a big course change). Only pypilot's
|
|
25
|
+
// directional overcurrent fault - the thing a cycle actually clears - counts.
|
|
26
|
+
exports.NATIVE_TACK_HOLDOFF_MS = 20000;
|
|
27
|
+
exports.TARGET_CHANGE_RESET_DEG = 10;
|
|
22
28
|
exports.MAX_CYCLES = 3;
|
|
23
29
|
exports.CYCLE_WINDOW_MS = 60000;
|
|
24
30
|
exports.CYCLE_MIN_GAP_MS = 10000;
|
|
@@ -39,6 +45,8 @@ class SteerWatch {
|
|
|
39
45
|
this.gaveUp = false;
|
|
40
46
|
this.droppedAtMs = null;
|
|
41
47
|
this.dirFault = false;
|
|
48
|
+
this.lastNativeTackMs = null;
|
|
49
|
+
this.lastCmdDeg = null;
|
|
42
50
|
}
|
|
43
51
|
tick(i) {
|
|
44
52
|
const now = i.nowMs;
|
|
@@ -55,6 +63,26 @@ class SteerWatch {
|
|
|
55
63
|
if (this.engagedSinceMs == null)
|
|
56
64
|
this.engagedSinceMs = now;
|
|
57
65
|
this.droppedAtMs = null;
|
|
66
|
+
if (i.nativeTack)
|
|
67
|
+
this.lastNativeTackMs = now;
|
|
68
|
+
const inTackHoldoff = this.lastNativeTackMs != null && now - this.lastNativeTackMs < exports.NATIVE_TACK_HOLDOFF_MS;
|
|
69
|
+
if (typeof i.cmdDeg === "number" && isFinite(i.cmdDeg)) {
|
|
70
|
+
if (this.lastCmdDeg != null) {
|
|
71
|
+
const jump = Math.abs(((i.cmdDeg - this.lastCmdDeg) % 360 + 540) % 360 - 180);
|
|
72
|
+
if (jump > exports.TARGET_CHANGE_RESET_DEG) {
|
|
73
|
+
// A new target is a new error baseline (sailor's course change).
|
|
74
|
+
this.errHist = [];
|
|
75
|
+
this.condSinceMs = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.lastCmdDeg = i.cmdDeg;
|
|
79
|
+
}
|
|
80
|
+
if (inTackHoldoff) {
|
|
81
|
+
this.errHist = [];
|
|
82
|
+
this.condSinceMs = null;
|
|
83
|
+
this.stuck = false;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
58
86
|
if (typeof i.errDeg !== "number" || !isFinite(i.errDeg)) {
|
|
59
87
|
this.errHist = [];
|
|
60
88
|
this.condSinceMs = null;
|
|
@@ -68,8 +96,7 @@ class SteerWatch {
|
|
|
68
96
|
}
|
|
69
97
|
const graceOk = now - this.engagedSinceMs >= exports.ENGAGE_GRACE_MS;
|
|
70
98
|
const bigErr = absErr > exports.STUCK_ERR_DEG;
|
|
71
|
-
const notPushing = this.dirFault
|
|
72
|
-
|| (typeof i.servoCurrentA === "number" && i.servoCurrentA < exports.IDLE_CURRENT_A);
|
|
99
|
+
const notPushing = this.dirFault;
|
|
73
100
|
const notConverging = this.notConverging(now, absErr);
|
|
74
101
|
const cond = graceOk && bigErr && notPushing && notConverging;
|
|
75
102
|
if (!cond) {
|
package/dist/trip-recorder.js
CHANGED
|
@@ -323,8 +323,22 @@ class TripRecorder {
|
|
|
323
323
|
clearInterval(this.flushTimer);
|
|
324
324
|
this.flushTimer = null;
|
|
325
325
|
}
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
const summary = this._buildSummary(endTs);
|
|
327
|
+
try {
|
|
328
|
+
if (this.currentSummaryFile)
|
|
329
|
+
fs.writeFileSync(this.currentSummaryFile, JSON.stringify(summary, null, 2));
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
this.log("error", `trip summary write failed: ${e?.message || e}`);
|
|
333
|
+
}
|
|
334
|
+
this.log("info", `[trip] closed ${this.currentId} (${this.sampleCount} samples, ${summary.distanceNm.toFixed(1)} nm, ${this.tackCount} tacks)`);
|
|
335
|
+
this._resetActive();
|
|
336
|
+
return summary;
|
|
337
|
+
}
|
|
338
|
+
// Rev516: shared by stop() and liveSummary().
|
|
339
|
+
_buildSummary(endTs) {
|
|
340
|
+
const durMs = endTs - this.startTs;
|
|
341
|
+
return {
|
|
328
342
|
id: this.currentId,
|
|
329
343
|
startTs: this.startTs,
|
|
330
344
|
endTs,
|
|
@@ -360,17 +374,18 @@ class TripRecorder {
|
|
|
360
374
|
revision: this.header?.revision ?? "?",
|
|
361
375
|
sampleCount: this.sampleCount,
|
|
362
376
|
};
|
|
363
|
-
try {
|
|
364
|
-
if (this.currentSummaryFile)
|
|
365
|
-
fs.writeFileSync(this.currentSummaryFile, JSON.stringify(summary, null, 2));
|
|
366
|
-
}
|
|
367
|
-
catch (e) {
|
|
368
|
-
this.log("error", `trip summary write failed: ${e?.message || e}`);
|
|
369
|
-
}
|
|
370
|
-
this.log("info", `[trip] closed ${this.currentId} (${this.sampleCount} samples, ${summary.distanceNm.toFixed(1)} nm, ${this.tackCount} tacks)`);
|
|
371
|
-
this._resetActive();
|
|
372
|
-
return summary;
|
|
373
377
|
}
|
|
378
|
+
/** Rev516 (Carlos 2026-10-19: "que lo que se está grabando de bitácora
|
|
379
|
+
* se pueda consultar mientras está navegando"): summary of the trip in
|
|
380
|
+
* progress from the same running stats stop() uses. */
|
|
381
|
+
liveSummary() {
|
|
382
|
+
if (!this.currentFile || this.startTs == null || !this.currentId)
|
|
383
|
+
return null;
|
|
384
|
+
return { ...this._buildSummary(Date.now()), live: true };
|
|
385
|
+
}
|
|
386
|
+
/** Rev516: write the pending samples so a download of the trip in
|
|
387
|
+
* progress carries the track up to now (buffer flushes every 30 s). */
|
|
388
|
+
flushNow() { this.flush(); }
|
|
374
389
|
flush() {
|
|
375
390
|
if (!this.currentFile || this.buffer.length === 0)
|
|
376
391
|
return;
|
|
@@ -504,6 +519,8 @@ class TripRecorder {
|
|
|
504
519
|
}
|
|
505
520
|
}
|
|
506
521
|
summary(id) {
|
|
522
|
+
if (this.currentId && id === this.currentId)
|
|
523
|
+
return this.liveSummary();
|
|
507
524
|
const sumPath = path.join(this.dir, `trip-${id}.summary.json`);
|
|
508
525
|
try {
|
|
509
526
|
return JSON.parse(fs.readFileSync(sumPath, "utf8"));
|
package/dist/virtual-tack.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.signedAngleDelta = signedAngleDelta;
|
|
|
30
30
|
exports.computeTackGeometry = computeTackGeometry;
|
|
31
31
|
exports.fractionateRotation = fractionateRotation;
|
|
32
32
|
exports.makeInitialState = makeInitialState;
|
|
33
|
+
exports.needsLongJibeConfirm = needsLongJibeConfirm;
|
|
33
34
|
exports.adjustedFinalCompassRad = adjustedFinalCompassRad;
|
|
34
35
|
exports.adjustedFinalWindRad = adjustedFinalWindRad;
|
|
35
36
|
exports.accumulateTackAdjust = accumulateTackAdjust;
|
|
@@ -252,6 +253,15 @@ function makeInitialState() {
|
|
|
252
253
|
}
|
|
253
254
|
/** Rev506: largest total nudge accepted during one manoeuvre. */
|
|
254
255
|
exports.MAX_TACK_ADJUST_RAD = 60 * exports.DEG;
|
|
256
|
+
/** Rev511 (Carlos sea trial 2026-10-18 18:11:44): a second tap on the
|
|
257
|
+
* same button after a cancelled tack, with the wind now on the other
|
|
258
|
+
* bow, asked for a 278 deg gybe from close-hauled in 25 kn. A gybe the
|
|
259
|
+
* long way (rotation > 180 deg, only possible from |AWA| < 90) needs an
|
|
260
|
+
* explicit confirmation. Normal gybes from a run/broad reach and long
|
|
261
|
+
* tacks from downwind are unaffected. */
|
|
262
|
+
function needsLongJibeConfirm(g) {
|
|
263
|
+
return g.maneuverKind === "jibe" && Math.abs(g.deltaHRad) > Math.PI;
|
|
264
|
+
}
|
|
255
265
|
/** Rev506: final compass heading of the rotation phase after the
|
|
256
266
|
* sailor's nudges. Bow to starboard = heading increases. */
|
|
257
267
|
function adjustedFinalCompassRad(g, adjustRad) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-pypilot-newui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Signal K friendly UI for the pypilot autopilot, with first-class Signal K paths and Simple Switches so any downstream Signal K client can drive or monitor the pilot. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings). Coexists with pypilot-autopilot-provider.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"files": [
|
package/public/app.js
CHANGED
|
@@ -462,6 +462,10 @@
|
|
|
462
462
|
"mode.label.compass": "COMPASS",
|
|
463
463
|
"mode.label.gps": "GPS",
|
|
464
464
|
"mode.label.wind": "APPARENT WIND",
|
|
465
|
+
"dlg.yes": "Yes",
|
|
466
|
+
"dlg.no": "No",
|
|
467
|
+
"vt.longJibe.title": "Gybe the long way?",
|
|
468
|
+
"vt.longJibe.body": "The wind is on the other bow: this button turns through the stern ({deg}°). To tack, use the other button.",
|
|
465
469
|
"mode.label.true-wind": "TRUE WIND",
|
|
466
470
|
"mode.label.nav": "WAYPOINT",
|
|
467
471
|
"mode.label.aproado": "HEAD TO WIND",
|
|
@@ -739,6 +743,8 @@
|
|
|
739
743
|
"trips.caption.voltMin": "Min volt",
|
|
740
744
|
"trips.caption.sags": "sags",
|
|
741
745
|
"trips.status.recording": "LOGBOOK ENTRY IN PROGRESS for",
|
|
746
|
+
"trips.live": "IN PROGRESS",
|
|
747
|
+
"tackStats.hdg": "HDG",
|
|
742
748
|
"trips.status.navState": "Nav state",
|
|
743
749
|
"trips.status.noneOpen": "no logbook entry in progress.",
|
|
744
750
|
"trips.status.disabled": "Logbook recording is OFF.",
|
|
@@ -1395,6 +1401,10 @@
|
|
|
1395
1401
|
"mode.label.compass": "COMPÁS",
|
|
1396
1402
|
"mode.label.gps": "GPS",
|
|
1397
1403
|
"mode.label.wind": "APARENTE",
|
|
1404
|
+
"dlg.yes": "Sí",
|
|
1405
|
+
"dlg.no": "No",
|
|
1406
|
+
"vt.longJibe.title": "¿Trasluchar por popa?",
|
|
1407
|
+
"vt.longJibe.body": "El viento está en la otra amura: este botón da la vuelta por popa ({deg}°). Para virar, usa el otro botón.",
|
|
1398
1408
|
"mode.label.true-wind": "REAL",
|
|
1399
1409
|
"mode.label.nav": "RUMBO A WAYPOINT",
|
|
1400
1410
|
"mode.label.aproado": "APROADO",
|
|
@@ -1678,6 +1688,8 @@
|
|
|
1678
1688
|
"trips.caption.voltMin": "Volt min",
|
|
1679
1689
|
"trips.caption.sags": "caidas",
|
|
1680
1690
|
"trips.status.recording": "BITÁCORA EN CURSO desde hace",
|
|
1691
|
+
"trips.live": "EN CURSO",
|
|
1692
|
+
"tackStats.hdg": "RUMBO",
|
|
1681
1693
|
"trips.status.navState": "Estado nav",
|
|
1682
1694
|
"trips.status.noneOpen": "sin bitácora en curso.",
|
|
1683
1695
|
"trips.status.disabled": "Grabación de bitácora OFF.",
|
|
@@ -1998,6 +2010,8 @@
|
|
|
1998
2010
|
"mode.label.compass": "KOMPASS",
|
|
1999
2011
|
"mode.label.gps": "GPS",
|
|
2000
2012
|
"mode.label.wind": "SCHEINBARER WIND",
|
|
2013
|
+
"dlg.yes": "Ja",
|
|
2014
|
+
"dlg.no": "Nein",
|
|
2001
2015
|
"mode.label.true-wind": "WAHRER WIND",
|
|
2002
2016
|
"mode.label.nav": "WEGPUNKT",
|
|
2003
2017
|
"mode.label.aproado": "AUFSCHIESSEN",
|
|
@@ -2197,6 +2211,8 @@
|
|
|
2197
2211
|
"mode.label.compass": "COMPAS",
|
|
2198
2212
|
"mode.label.gps": "GPS",
|
|
2199
2213
|
"mode.label.wind": "VENT APPARENT",
|
|
2214
|
+
"dlg.yes": "Oui",
|
|
2215
|
+
"dlg.no": "Non",
|
|
2200
2216
|
"mode.label.true-wind": "VENT REEL",
|
|
2201
2217
|
"mode.label.nav": "WAYPOINT",
|
|
2202
2218
|
"mode.label.aproado": "FACE AU VENT",
|
|
@@ -2956,6 +2972,7 @@
|
|
|
2956
2972
|
try { renderTargetArrow(); } catch {}
|
|
2957
2973
|
}
|
|
2958
2974
|
try { setSelect("#mode-select", state.virtualTack.windMode || state.mode); } catch {}
|
|
2975
|
+
try { _renderModeSelectBtn(); } catch {}
|
|
2959
2976
|
try { _lastTackDirSeen = state.virtualTack.direction; } catch {}
|
|
2960
2977
|
try { if (typeof _openExternalTackOverlay === "function") _openExternalTackOverlay(state.virtualTack); } catch (e) { console.warn("[vt] open overlay", e); }
|
|
2961
2978
|
}
|
|
@@ -2984,10 +3001,18 @@
|
|
|
2984
3001
|
} else if (typeof state._targetBeforeVt === "number") {
|
|
2985
3002
|
state.target = state._targetBeforeVt;
|
|
2986
3003
|
}
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
3004
|
+
// Rev510: the FSM restores its windMode on completed, cancelled
|
|
3005
|
+
// and failed. _modeBeforeVt could hold "compass" when the
|
|
3006
|
+
// FSM's own compass delta beat the first VT snapshot to this
|
|
3007
|
+
// device - that left one screen on COMPASS and another on WIND.
|
|
3008
|
+
const modeAfter = (typeof state.virtualTack?.windMode === "string" && state.virtualTack.windMode)
|
|
3009
|
+
? state.virtualTack.windMode
|
|
3010
|
+
: state._modeBeforeVt;
|
|
3011
|
+
if (typeof modeAfter === "string") {
|
|
3012
|
+
state.mode = modeAfter;
|
|
3013
|
+
try { setSelect("#mode-select", modeAfter); } catch {}
|
|
2990
3014
|
}
|
|
3015
|
+
try { _renderModeSelectBtn(); } catch {}
|
|
2991
3016
|
// Rev410 (fix U.3, Carlos 2026-10-04): explicit failure toast
|
|
2992
3017
|
// fed by the backend's `outcomeReason`, replacing the fragile
|
|
2993
3018
|
// time-since-delta heuristic. Fires on `failed` only — a
|
|
@@ -5611,20 +5636,56 @@
|
|
|
5611
5636
|
// hudOwner semantics: overlay open + _externalManeuver=false
|
|
5612
5637
|
// means the local path owns it; we must not intrude.
|
|
5613
5638
|
if (overlayOpen && !_externalManeuver) return;
|
|
5639
|
+
// Rev515: a new tack while the previous one is still finishing.
|
|
5640
|
+
if (isActive && !wasActive && _externalArrivalTimer) {
|
|
5641
|
+
_closeExternalTackOverlay();
|
|
5642
|
+
}
|
|
5614
5643
|
if (isActive && !wasActive && !_externalManeuver) {
|
|
5615
5644
|
_openExternalTackOverlay();
|
|
5616
5645
|
return;
|
|
5617
5646
|
}
|
|
5618
5647
|
if (!isActive && _externalManeuver) {
|
|
5648
|
+
// Rev515 (Carlos 2026-10-19 18:55: "se desactiva el HUD antes de
|
|
5649
|
+
// terminar la virada"): pypilot ends its tack at ap.tack.threshold
|
|
5650
|
+
// (50 %) with ~40 deg still to turn. For pypilot's own tacks keep
|
|
5651
|
+
// the HUD until the boat is within 10 deg of the new target, max
|
|
5652
|
+
// 15 s. Virtual tacks close from their own snapshot as before.
|
|
5653
|
+
if (!state.virtualTack) { _closeExternalWhenArrived(); return; }
|
|
5619
5654
|
_closeExternalTackOverlay();
|
|
5620
5655
|
}
|
|
5621
5656
|
}
|
|
5657
|
+
let _externalArrivalTimer = null;
|
|
5658
|
+
function _closeExternalWhenArrived() {
|
|
5659
|
+
if (_externalArrivalTimer) return;
|
|
5660
|
+
const t0 = Date.now();
|
|
5661
|
+
_externalArrivalTimer = setInterval(() => {
|
|
5662
|
+
const modeStr = String(state.mode || "").toLowerCase();
|
|
5663
|
+
const cur = modeStr.includes("wind")
|
|
5664
|
+
? (modeStr.includes("true") ? state.windAngleTrue : state.windAngle)
|
|
5665
|
+
: state.heading;
|
|
5666
|
+
let arrived = false;
|
|
5667
|
+
if (typeof cur === "number" && typeof state.target === "number") {
|
|
5668
|
+
let e = (cur - state.target) * RAD2DEG;
|
|
5669
|
+
while (e > 180) e -= 360;
|
|
5670
|
+
while (e < -180) e += 360;
|
|
5671
|
+
arrived = Math.abs(e) < 10;
|
|
5672
|
+
}
|
|
5673
|
+
if (arrived || Date.now() - t0 > 15000 || !_externalManeuver) {
|
|
5674
|
+
clearInterval(_externalArrivalTimer);
|
|
5675
|
+
_externalArrivalTimer = null;
|
|
5676
|
+
if (_externalManeuver) _closeExternalTackOverlay();
|
|
5677
|
+
}
|
|
5678
|
+
}, 500);
|
|
5679
|
+
}
|
|
5622
5680
|
function _openExternalTackOverlay(vt = null) {
|
|
5623
5681
|
// Rev358 (Carlos, 2026-09-30, QA Rev355): a new external maneuver
|
|
5624
5682
|
// arrived — close any prior stats HUD so it does not stay behind
|
|
5625
5683
|
// the mirror overlay and get overwritten later when this new tack
|
|
5626
5684
|
// completes.
|
|
5627
5685
|
try { if (_tackStats.hideAt) _tackStatsHide(); } catch { /* silent */ }
|
|
5686
|
+
// Rev515 (Carlos 2026-10-19 19:19): the previous manoeuvre's post
|
|
5687
|
+
// window (5 s + 10 s) re-showed its summary in the middle of this one.
|
|
5688
|
+
_tackStats.postScheduledFor = 0;
|
|
5628
5689
|
// Rev395 (Carlos, 2026-10-02, Round-3): prefer the backend snapshot
|
|
5629
5690
|
// over local heuristics for direction, mode and maneuverKind. The
|
|
5630
5691
|
// backend knows WHICH side it is driving and WHICH classification
|
|
@@ -5733,19 +5794,34 @@
|
|
|
5733
5794
|
// `remainingDeg` is no longer the useful metric. Show a short
|
|
5734
5795
|
// "settling" label so Carlos reads the correct phase.
|
|
5735
5796
|
const vt = state.virtualTack;
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5797
|
+
// Rev515: wind arrow -> diamond (what the rose shows), also while
|
|
5798
|
+
// settling; no "ajustando" text (Carlos: read as a new mode).
|
|
5799
|
+
const inSettling = false;
|
|
5800
|
+
const remDeg = (vt && vt.active)
|
|
5801
|
+
? (_vtWindRemainingDeg(vt) ?? (typeof vt.remainingDeg === "number" ? vt.remainingDeg : null))
|
|
5802
|
+
: null;
|
|
5803
|
+
if (remDeg !== null) {
|
|
5742
5804
|
el.textContent = `${Math.round(remDeg)}°`;
|
|
5743
5805
|
} else {
|
|
5744
5806
|
el.textContent = "— —";
|
|
5745
5807
|
}
|
|
5808
|
+
// Rev510: "de Y°" under the number, like the device that started it.
|
|
5809
|
+
const degLine = document.getElementById("tack-countdown-deg");
|
|
5810
|
+
if (degLine) {
|
|
5811
|
+
if (vt && vt.active && typeof vt.totalDeg === "number" && vt.totalDeg > 0 && !inSettling) {
|
|
5812
|
+
degLine.textContent = `de ${Math.round(vt.totalDeg)}°`;
|
|
5813
|
+
degLine.style.display = "";
|
|
5814
|
+
} else {
|
|
5815
|
+
degLine.style.display = "none";
|
|
5816
|
+
}
|
|
5817
|
+
}
|
|
5746
5818
|
}
|
|
5747
5819
|
// Re-detect kind if direction/AWA showed up after open.
|
|
5748
|
-
|
|
5820
|
+
// Rev515 (Carlos 2026-10-19 18:53-18:55, HUD turning the wrong way):
|
|
5821
|
+
// only when the direction was unknown at open. pypilot's wind log
|
|
5822
|
+
// (tacking.py poll) overwrites ap.tack.direction with the wind side
|
|
5823
|
+
// ~0.5 s after the remote's begin; the value at begin is the turn.
|
|
5824
|
+
if (!_tackCountdownDir && _lastTackDirSeen && _lastTackDirSeen !== "none") {
|
|
5749
5825
|
_tackCountdownDir = _lastTackDirSeen === "port" ? "port" : "starboard";
|
|
5750
5826
|
try { _refreshOverlayKind(); } catch { /* silent */ }
|
|
5751
5827
|
}
|
|
@@ -5772,6 +5848,7 @@
|
|
|
5772
5848
|
}
|
|
5773
5849
|
}
|
|
5774
5850
|
function _closeExternalTackOverlay() {
|
|
5851
|
+
if (_externalArrivalTimer) { clearInterval(_externalArrivalTimer); _externalArrivalTimer = null; }
|
|
5775
5852
|
if (_externalManeuverTimer) { clearInterval(_externalManeuverTimer); _externalManeuverTimer = null; }
|
|
5776
5853
|
if (_externalManeuverWatchdog) { clearTimeout(_externalManeuverWatchdog); _externalManeuverWatchdog = null; }
|
|
5777
5854
|
_externalManeuver = false;
|
|
@@ -5802,6 +5879,9 @@
|
|
|
5802
5879
|
// does not stay behind the new countdown and confuse the sailor
|
|
5803
5880
|
// when the new maneuver completes and overwrites its content.
|
|
5804
5881
|
try { if (_tackStats.hideAt) _tackStatsHide(); } catch { /* silent */ }
|
|
5882
|
+
// Rev515 (Carlos 2026-10-19 19:19): the previous manoeuvre's post
|
|
5883
|
+
// window (5 s + 10 s) re-showed its summary in the middle of this one.
|
|
5884
|
+
_tackStats.postScheduledFor = 0;
|
|
5805
5885
|
_tackCountdownKind = MANEUVER_STYLES[kind] ? kind : "tack";
|
|
5806
5886
|
_tackDiamondBlink = true;
|
|
5807
5887
|
renderTargetArrow();
|
|
@@ -6114,6 +6194,8 @@
|
|
|
6114
6194
|
let _externalManeuverTimer = null; // pure UI refresh, no logic
|
|
6115
6195
|
let _externalManeuverWatchdog = null; // 60 s safety close
|
|
6116
6196
|
function _tackCountdownDegTotal() {
|
|
6197
|
+
const vt = _vtActiveSnapshot();
|
|
6198
|
+
if (vt && typeof vt.totalDeg === "number" && vt.totalDeg > 0) return Math.round(vt.totalDeg);
|
|
6117
6199
|
if (_tackAccumulatedRotationDeg > 0) return Math.round(_tackAccumulatedRotationDeg);
|
|
6118
6200
|
const raw = state.pypilotValues?.["ap.tack.angle"];
|
|
6119
6201
|
if (typeof raw === "number" && raw > 10 && raw < 180) return Math.round(raw);
|
|
@@ -6169,7 +6251,41 @@
|
|
|
6169
6251
|
const expectSign = isWind ? -hullSign : hullSign;
|
|
6170
6252
|
_tackRotationDoneDeg = Math.max(0, _tackRotationDoneDeg + deltaDeg * expectSign);
|
|
6171
6253
|
}
|
|
6254
|
+
// Rev510 (Carlos sea trial 2026-10-18: "te pone los grados, pero no los
|
|
6255
|
+
// que le quedan por llegar"): while a virtual tack runs, the backend's
|
|
6256
|
+
// remainingDeg (real heading vs the FINAL compass target, +-10 included)
|
|
6257
|
+
// is the truth. The local AWA integration drifted with gusts, the boat
|
|
6258
|
+
// slowing down and the sailor's adjustments.
|
|
6259
|
+
function _vtActiveSnapshot() {
|
|
6260
|
+
const vt = state.virtualTack;
|
|
6261
|
+
return (vt && vt.active) ? vt : null;
|
|
6262
|
+
}
|
|
6263
|
+
// Rev515 (Carlos 2026-10-19: "el contador miente … no se corresponde
|
|
6264
|
+
// flecha viento con target y dato"): count what the rose shows - the
|
|
6265
|
+
// wind arrow's distance to the target diamond, along the turn. Near the
|
|
6266
|
+
// target (settling, overshoot) the plain error; no "ajustando" text.
|
|
6267
|
+
function _vtWindRemainingDeg(vt) {
|
|
6268
|
+
if (!vt || typeof vt.finalWindTargetRad !== "number") return null;
|
|
6269
|
+
const useTrue = String(vt.windMode || "").toLowerCase().includes("true");
|
|
6270
|
+
const cur = useTrue ? state.windAngleTrue : state.windAngle;
|
|
6271
|
+
if (typeof cur !== "number") return null;
|
|
6272
|
+
let e = (cur - vt.finalWindTargetRad) * RAD2DEG;
|
|
6273
|
+
while (e > 180) e -= 360;
|
|
6274
|
+
while (e < -180) e += 360;
|
|
6275
|
+
if (Math.abs(e) < 30) return Math.round(Math.abs(e));
|
|
6276
|
+
// Hull to starboard -> wind angle decreases (and vice versa).
|
|
6277
|
+
const s = vt.direction === "starboard" ? 1 : -1;
|
|
6278
|
+
let d = ((cur - vt.finalWindTargetRad) * RAD2DEG * s) % 360;
|
|
6279
|
+
if (d < 0) d += 360;
|
|
6280
|
+
return Math.round(d);
|
|
6281
|
+
}
|
|
6172
6282
|
function _tackCountdownDegRemaining() {
|
|
6283
|
+
const vt = _vtActiveSnapshot();
|
|
6284
|
+
if (vt) {
|
|
6285
|
+
const w = _vtWindRemainingDeg(vt);
|
|
6286
|
+
if (w != null) return w;
|
|
6287
|
+
if (typeof vt.remainingDeg === "number") return Math.max(0, Math.round(vt.remainingDeg));
|
|
6288
|
+
}
|
|
6173
6289
|
const total = _tackAccumulatedRotationDeg > 0 ? _tackAccumulatedRotationDeg : null;
|
|
6174
6290
|
if (total == null) return null;
|
|
6175
6291
|
return Math.max(0, Math.round(total - _tackRotationDoneDeg));
|
|
@@ -6500,6 +6616,8 @@
|
|
|
6500
6616
|
awaSigned: typeof state.windAngle === "number" ? state.windAngle : null,
|
|
6501
6617
|
twaSigned: typeof state.windAngleTrue === "number" ? state.windAngleTrue : null,
|
|
6502
6618
|
sog: typeof state.sog === "number" ? state.sog * 1.94384 : null,
|
|
6619
|
+
// Rev516: heading for the compass-mode summary.
|
|
6620
|
+
hdg: typeof state.heading === "number" ? state.heading : null,
|
|
6503
6621
|
});
|
|
6504
6622
|
// Rev172 (Carlos): the gust-ghost detector piggybacks on this 1 Hz
|
|
6505
6623
|
// ring buffer - baseline AWS and rolling AWA both come from the
|
|
@@ -6531,8 +6649,10 @@
|
|
|
6531
6649
|
// Rev225 (Carlos): also keep the SIGN of AWA so the HUD can show
|
|
6532
6650
|
// "45deg B" or "45deg E" instead of the bare absolute value.
|
|
6533
6651
|
let awaSignedSum = 0, awaSignedN = 0;
|
|
6652
|
+
let hx = 0, hy = 0, hN = 0;
|
|
6534
6653
|
for (const s of _tackStats.samples) {
|
|
6535
6654
|
if (s.ts < fromTs || s.ts > toTs) continue;
|
|
6655
|
+
if (typeof s.hdg === "number") { hx += Math.cos(s.hdg); hy += Math.sin(s.hdg); hN += 1; }
|
|
6536
6656
|
if (typeof s.tws === "number") { tws += s.tws; twsN += 1; }
|
|
6537
6657
|
if (typeof s.awaAbs === "number") { awa += s.awaAbs; awaN += 1; }
|
|
6538
6658
|
if (typeof s.awaSigned === "number") {
|
|
@@ -6546,6 +6666,7 @@
|
|
|
6546
6666
|
awa: awaN > 0 ? awa / awaN : null,
|
|
6547
6667
|
awaSide: awaSignedN > 0 ? (awaSignedSum >= 0 ? "E" : "B") : null,
|
|
6548
6668
|
sog: sogN > 0 ? sog / sogN : null,
|
|
6669
|
+
hdg: hN > 0 ? ((Math.atan2(hy, hx) * RAD2DEG) + 360) % 360 : null,
|
|
6549
6670
|
};
|
|
6550
6671
|
}
|
|
6551
6672
|
function _tackStatsOnTackCompleted() {
|
|
@@ -6877,7 +6998,13 @@
|
|
|
6877
6998
|
// transform we could unknowingly inherit later.
|
|
6878
6999
|
svg.appendChild(ghost);
|
|
6879
7000
|
_gustGhostActive = { g: ghost, expireAt: Date.now() + GUST_GHOST_FADE_MS + 500 };
|
|
6880
|
-
|
|
7001
|
+
// Rev509 (Carlos 2026-10-18, "las rachas no salen casi nunca"): one
|
|
7002
|
+
// rAF is not enough - its callback runs before the browser has ever
|
|
7003
|
+
// styled the new node, so there is no 0.5 starting point and the
|
|
7004
|
+
// transition is skipped: the ghost went invisible at once (headless
|
|
7005
|
+
// Chrome: opacity 0 after 1 s with one rAF, 0.47 with two). The second
|
|
7006
|
+
// rAF starts the fade after the first frame painted it.
|
|
7007
|
+
requestAnimationFrame(() => requestAnimationFrame(() => { ghost.style.opacity = "0"; }));
|
|
6881
7008
|
setTimeout(() => {
|
|
6882
7009
|
try { ghost.remove(); } catch { /* silent */ }
|
|
6883
7010
|
_gustGhostActive = null;
|
|
@@ -6996,8 +7123,23 @@
|
|
|
6996
7123
|
if (o == null || o.awa == null) return (isPost && pending) ? "…" : "--";
|
|
6997
7124
|
return `${Math.round(o.awa)}° ${o.awaSide || ""}`.trim();
|
|
6998
7125
|
};
|
|
6999
|
-
|
|
7000
|
-
|
|
7126
|
+
// Rev516 (Carlos 2026-10-19: "cuando estamos en modo compás no debe
|
|
7127
|
+
// hablar del viento aparente sino del rumbo"): compass manoeuvre ->
|
|
7128
|
+
// mean heading before / after.
|
|
7129
|
+
// Frozen when the summary first shows, so a mode change before the
|
|
7130
|
+
// post window lands does not flip the row.
|
|
7131
|
+
if (post == null || _tackStats.compassStats == null) {
|
|
7132
|
+
const modeNow = String(_maneuverModeAtStart || state.mode || "").toLowerCase();
|
|
7133
|
+
_tackStats.compassStats = !modeNow.includes("wind");
|
|
7134
|
+
}
|
|
7135
|
+
const compassStats = _tackStats.compassStats;
|
|
7136
|
+
set("tack-stats-awa-lbl", compassStats ? (t("tackStats.hdg") || "HDG") : "AWA");
|
|
7137
|
+
const hdgFmt = (o, isPost) => {
|
|
7138
|
+
if (o == null || o.hdg == null) return (isPost && pending) ? "…" : "--";
|
|
7139
|
+
return `${String(Math.round(o.hdg) % 360).padStart(3, "0")}°`;
|
|
7140
|
+
};
|
|
7141
|
+
set("tack-stats-awa-before", compassStats ? hdgFmt(pre, false) : awaFmt(pre, false));
|
|
7142
|
+
set("tack-stats-awa-after", compassStats ? hdgFmt(post, true) : awaFmt(post, true));
|
|
7001
7143
|
set("tack-stats-sog-before", (pre?.sog == null ? "--" : pre.sog.toFixed(1) + " kn"));
|
|
7002
7144
|
set("tack-stats-sog-after", fmt(post?.sog, " kn", 1));
|
|
7003
7145
|
// Rev224: execution time (from arming-to-executing switch to
|
|
@@ -7319,10 +7461,16 @@
|
|
|
7319
7461
|
document.body.classList.add("adjusting");
|
|
7320
7462
|
row.classList.add("active");
|
|
7321
7463
|
});
|
|
7322
|
-
|
|
7464
|
+
// Rev515: Android turns a drag into a scroll (pointercancel, no
|
|
7465
|
+
// pointerup) and the tab stayed dimmed; clear on every end event.
|
|
7466
|
+
const endAdjust = () => {
|
|
7323
7467
|
document.body.classList.remove("adjusting");
|
|
7324
7468
|
row.classList.remove("active");
|
|
7325
|
-
}
|
|
7469
|
+
};
|
|
7470
|
+
rng.addEventListener("pointerup", endAdjust);
|
|
7471
|
+
rng.addEventListener("pointercancel", endAdjust);
|
|
7472
|
+
rng.addEventListener("lostpointercapture", endAdjust);
|
|
7473
|
+
rng.addEventListener("blur", endAdjust);
|
|
7326
7474
|
rng.addEventListener("input", () => { val.innerHTML = `${Number(rng.value).toFixed(decimals)}${unitHtml}`; });
|
|
7327
7475
|
rng.addEventListener("change", () => { try { opts.onChange(Number(rng.value)); } catch { /* silent */ } });
|
|
7328
7476
|
row.querySelectorAll(".nudge-btn").forEach((btn) => {
|
|
@@ -7527,7 +7675,13 @@
|
|
|
7527
7675
|
if (row.classList.contains("active")) return;
|
|
7528
7676
|
const rng = row.querySelector("input[type=range]");
|
|
7529
7677
|
const val = row.querySelector(".value");
|
|
7530
|
-
|
|
7678
|
+
// Half a slider step: the range input snaps 0.7 to 0.6945.
|
|
7679
|
+
const halfStep = rng ? (Number(rng.step) || 0) / 2 : 0;
|
|
7680
|
+
if (rng && Math.abs(Number(rng.value) - v) > Math.max(halfStep, 1e-6 * Math.max(1, Math.abs(v)))) {
|
|
7681
|
+
// Rev515: log every value the refresh changes on screen.
|
|
7682
|
+
try { _mtLog("other", { stage: "visor", via: "tune-refresh-change", name, from: Number(rng.value), to: v }); } catch { /* silent */ }
|
|
7683
|
+
rng.value = String(v);
|
|
7684
|
+
}
|
|
7531
7685
|
if (val) {
|
|
7532
7686
|
const unitStr = _gainUnits(name);
|
|
7533
7687
|
const unitHtml = unitStr
|
|
@@ -7777,10 +7931,24 @@
|
|
|
7777
7931
|
// Now uses skFetch (adds JWT if we have one), retries with backoff, and
|
|
7778
7932
|
// opens the login modal after 3 consecutive 401s.
|
|
7779
7933
|
let _refreshValues401Streak = 0;
|
|
7780
|
-
// Rev507: name ->
|
|
7781
|
-
//
|
|
7782
|
-
|
|
7783
|
-
|
|
7934
|
+
// Rev507: name -> the sailor's last successful RangeSetting write
|
|
7935
|
+
// (pluginRaw). Rev515 (Carlos 2026-10-19: "se siguen borrando los
|
|
7936
|
+
// cambios"): a fixed 5 s hold painted the old value back whenever the
|
|
7937
|
+
// backend had not received pypilot's echo yet. Now the written value
|
|
7938
|
+
// stays until the backend reports it (confirmed) or a THIRD value
|
|
7939
|
+
// (someone else changed it), at most PV_LOCAL_WRITE_MAX_MS.
|
|
7940
|
+
const _pvLocalWriteTs = new Map(); // name -> { value, prev, ts }
|
|
7941
|
+
const PV_LOCAL_WRITE_MAX_MS = 5 * 60_000;
|
|
7942
|
+
const _pvSame = (a, b) => typeof a === "number" && typeof b === "number" && Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a));
|
|
7943
|
+
// true = keep the sailor's value, false = take the fetched one.
|
|
7944
|
+
function _pvKeepLocal(k, fetchedV, nowTs) {
|
|
7945
|
+
const w = _pvLocalWriteTs.get(k);
|
|
7946
|
+
if (!w) return false;
|
|
7947
|
+
if (nowTs - w.ts > PV_LOCAL_WRITE_MAX_MS || _pvSame(fetchedV, w.value)) { _pvLocalWriteTs.delete(k); return false; }
|
|
7948
|
+
if (w.prev === undefined || _pvSame(fetchedV, w.prev)) return true; // stale echo
|
|
7949
|
+
_pvLocalWriteTs.delete(k); // changed elsewhere
|
|
7950
|
+
return false;
|
|
7951
|
+
}
|
|
7784
7952
|
async function refreshPypilotValues(opts) {
|
|
7785
7953
|
const replace = !!(opts && opts.replace);
|
|
7786
7954
|
try {
|
|
@@ -7803,17 +7971,13 @@
|
|
|
7803
7971
|
// {replace:true} for that case so the sliders reflect the new
|
|
7804
7972
|
// profile instead of the previous one.
|
|
7805
7973
|
const fetched = await r.json();
|
|
7806
|
-
// Rev507:
|
|
7807
|
-
// wins over the snapshot (pypilot may not have echoed it yet).
|
|
7974
|
+
// Rev507/515: the sailor's own write wins until confirmed.
|
|
7808
7975
|
const nowTs = Date.now();
|
|
7809
|
-
const freshLocal = (k) => {
|
|
7810
|
-
const ts = _pvLocalWriteTs.get(k);
|
|
7811
|
-
return ts != null && (nowTs - ts) < PV_LOCAL_WRITE_HOLD_MS;
|
|
7812
|
-
};
|
|
7976
|
+
const freshLocal = (k) => _pvKeepLocal(k, (fetched || {})[k], nowTs);
|
|
7813
7977
|
if (replace) {
|
|
7814
7978
|
const prev = state.pypilotValues || {};
|
|
7815
7979
|
state.pypilotValues = fetched || {};
|
|
7816
|
-
for (const k of _pvLocalWriteTs.keys()) {
|
|
7980
|
+
for (const k of Array.from(_pvLocalWriteTs.keys())) {
|
|
7817
7981
|
if (freshLocal(k) && prev[k] != null) state.pypilotValues[k] = prev[k];
|
|
7818
7982
|
}
|
|
7819
7983
|
} else {
|
|
@@ -8091,6 +8255,17 @@
|
|
|
8091
8255
|
function _renderModeSelectBtn() {
|
|
8092
8256
|
const btn = document.getElementById("mode-select-btn");
|
|
8093
8257
|
if (!btn) return;
|
|
8258
|
+
// Rev510 (Carlos sea trial 2026-10-18, 18:07-18:12: "la tablet decia
|
|
8259
|
+
// un modo y el telefono decia otro"): during a virtual tack pypilot is
|
|
8260
|
+
// in compass on purpose. Every visor shows the wind mode of the tack,
|
|
8261
|
+
// from the backend snapshot (same on every screen). Rev515 (Carlos
|
|
8262
|
+
// 2026-10-19): no "TACKING ->" prefix, it read as a new mode.
|
|
8263
|
+
const vt = state.virtualTack;
|
|
8264
|
+
if (vt && vt.active) {
|
|
8265
|
+
const txt = _modeLabel(vt.windMode || state.mode || "wind");
|
|
8266
|
+
if (btn.textContent !== txt) btn.textContent = txt;
|
|
8267
|
+
return;
|
|
8268
|
+
}
|
|
8094
8269
|
const m = state.mode || "";
|
|
8095
8270
|
btn.textContent = m ? _modeLabel(m) : "—";
|
|
8096
8271
|
}
|
|
@@ -9892,8 +10067,11 @@
|
|
|
9892
10067
|
if (res.ok && state.catalog?.[name]?.type === "RangeSetting"
|
|
9893
10068
|
&& typeof value === "number" && isFinite(value)) {
|
|
9894
10069
|
if (!state.pypilotValues) state.pypilotValues = {};
|
|
10070
|
+
const prevV = state.pypilotValues[name];
|
|
9895
10071
|
state.pypilotValues[name] = value;
|
|
9896
|
-
_pvLocalWriteTs.set(name, Date.now());
|
|
10072
|
+
_pvLocalWriteTs.set(name, { value, prev: prevV, ts: Date.now() });
|
|
10073
|
+
// Rev515: evidence for "se borran los cambios" next time.
|
|
10074
|
+
try { _mtLog("other", { stage: "visor", via: "tune-write", name, value, prev: prevV }); } catch { /* silent */ }
|
|
9897
10075
|
}
|
|
9898
10076
|
if (!res.ok) {
|
|
9899
10077
|
const bodyTxt = await res.text().catch(() => "");
|
|
@@ -10630,6 +10808,7 @@
|
|
|
10630
10808
|
// (500ms each → ~2s total) swallowing 409s silently, then
|
|
10631
10809
|
// only flash fail on persistent failure. Other HTTP errors
|
|
10632
10810
|
// (400/500/network) still fail immediately.
|
|
10811
|
+
let confirmLongJibe = false;
|
|
10633
10812
|
const vtStart = async () => {
|
|
10634
10813
|
// Rev400 (Carlos QA Rev399): subo retries 4→10 (~5s total).
|
|
10635
10814
|
// Rev399 QA mostró casos donde el VT previo tardaba >2s en
|
|
@@ -10644,6 +10823,7 @@
|
|
|
10644
10823
|
// Clamped to ±170° (deg) to prevent abuse; backend
|
|
10645
10824
|
// additionally rejects anything outside (-π, π].
|
|
10646
10825
|
const body = { direction: dir, requestId };
|
|
10826
|
+
if (confirmLongJibe) body.confirmLongJibe = true;
|
|
10647
10827
|
try {
|
|
10648
10828
|
const rawOv = localStorage.getItem("pypilotnewui.tackFinalAngleDeg");
|
|
10649
10829
|
const deg = rawOv == null ? NaN : Number(rawOv);
|
|
@@ -10661,7 +10841,20 @@
|
|
|
10661
10841
|
await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
|
|
10662
10842
|
}
|
|
10663
10843
|
};
|
|
10664
|
-
|
|
10844
|
+
let r = await vtStart();
|
|
10845
|
+
// Rev511 (Carlos sea trial 2026-10-18 18:11:44): the backend
|
|
10846
|
+
// asks before a gybe the long way (> 180 deg from close-hauled).
|
|
10847
|
+
if (r.status === 428) {
|
|
10848
|
+
const j = await r.json().catch(() => ({}));
|
|
10849
|
+
const deg = Math.round(Math.abs(Number(j.deltaDeg) || 0));
|
|
10850
|
+
const ok = await _pmConfirm(
|
|
10851
|
+
t("vt.longJibe.title") || "¿Trasluchar por popa?",
|
|
10852
|
+
(t("vt.longJibe.body") || "El viento está en la otra amura: este botón da la vuelta por popa ({deg}°). Para virar, usa el otro botón.").replace("{deg}", String(deg)),
|
|
10853
|
+
);
|
|
10854
|
+
if (!ok) return;
|
|
10855
|
+
confirmLongJibe = true;
|
|
10856
|
+
r = await vtStart();
|
|
10857
|
+
}
|
|
10665
10858
|
if (!r.ok) {
|
|
10666
10859
|
const err = await r.text().catch(() => String(r.status));
|
|
10667
10860
|
console.warn(`[vt] start failed: ${err}`);
|
|
@@ -12470,10 +12663,15 @@
|
|
|
12470
12663
|
// Rev330 (Carlos, 2026-09-28): tipografía más grande + colores del
|
|
12471
12664
|
// sistema (--fg, --fg-dim, --accent). Nombre en accent cuando
|
|
12472
12665
|
// existe, fecha siempre en fg-dim.
|
|
12666
|
+
// Rev516 (Carlos 2026-10-19: consult the logbook while sailing): the
|
|
12667
|
+
// trip being recorded shows live stats and this badge.
|
|
12668
|
+
const liveBadge = trip.live
|
|
12669
|
+
? `<span style="display:inline-block;margin-left:8px;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:0.5px;color:#ff9500;background:rgba(255,149,0,0.15);border:1px solid #ff9500;vertical-align:middle">● ${t("trips.live")}</span>`
|
|
12670
|
+
: "";
|
|
12473
12671
|
const titleHtml = nameEsc
|
|
12474
|
-
? `<div style="font-size:16px;font-weight:600;color:var(--accent,#4dd4ff);line-height:1.2">${nameEsc}</div>
|
|
12672
|
+
? `<div style="font-size:16px;font-weight:600;color:var(--accent,#4dd4ff);line-height:1.2">${nameEsc}${liveBadge}</div>
|
|
12475
12673
|
<div style="font-size:13px;color:var(--fg-dim);margin-top:2px">${dateStr}</div>`
|
|
12476
|
-
: `<div style="font-size:15px;font-weight:600;color:var(--fg);line-height:1.2">${dateStr}</div>`;
|
|
12674
|
+
: `<div style="font-size:15px;font-weight:600;color:var(--fg);line-height:1.2">${dateStr}${liveBadge}</div>`;
|
|
12477
12675
|
const kpiHtml = s
|
|
12478
12676
|
? `<div style="font-size:13px;color:var(--fg);margin-top:6px"><b>${distNm} nm</b> · ${durMin} min · SOG ${avg} ${sog} kn</div>
|
|
12479
12677
|
<div style="font-size:12px;color:var(--fg-dim);margin-top:2px">TWS ${avg} ${tws} kn · ${tacks} ${bordos} · peak SOG ${s.sogMaxKn?.toFixed(1) ?? "?"} kn · peak TWS ${s.twsMaxKn?.toFixed(1) ?? "?"} kn</div>`
|
|
@@ -13473,6 +13671,11 @@
|
|
|
13473
13671
|
// set, falling back to the timestamp. Inline pencil button reopens
|
|
13474
13672
|
// the rename prompt.
|
|
13475
13673
|
_tripsRenderTitle(id, s.name || null, dateStr);
|
|
13674
|
+
if (s.live) {
|
|
13675
|
+
// Rev516: trip still being recorded - stats and track up to now.
|
|
13676
|
+
const titleEl = document.getElementById("trip-detail-title");
|
|
13677
|
+
if (titleEl) titleEl.insertAdjacentHTML("beforeend", `<span style="margin-left:8px;padding:1px 7px;border-radius:4px;font-size:10px;font-weight:700;color:#ff9500;background:rgba(255,149,0,0.15);border:1px solid #ff9500">● ${t("trips.live")}</span>`);
|
|
13678
|
+
}
|
|
13476
13679
|
// Rev332 (Carlos, 2026-09-28): "quita los colorines" — todas las
|
|
13477
13680
|
// KPI cards con el mismo color neutro var(--fg). El tint arg se
|
|
13478
13681
|
// conserva por si un día queremos volver a resaltar algo, pero por
|
package/public/index.html
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
style.css never shows the rose unstyled on a white page. The rose
|
|
28
28
|
is hidden until style.css (loaded below) makes it visible again. -->
|
|
29
29
|
<style>html,body{background:#0a1220;color:#e6edf3}#wind-rose{visibility:hidden}</style>
|
|
30
|
-
<link rel="stylesheet" href="style.css?v=
|
|
30
|
+
<link rel="stylesheet" href="style.css?v=Rev516" />
|
|
31
31
|
</head>
|
|
32
32
|
<body class="theme-dark">
|
|
33
33
|
<!-- Rev490 (GPU audit 2026-10-15): flag the Raspberry Pi's own screen
|
|
@@ -286,7 +286,7 @@
|
|
|
286
286
|
fill="#ffffff" font-size="12" font-weight="800"
|
|
287
287
|
font-family="ui-monospace, monospace">--</text>
|
|
288
288
|
<line x1="-72" y1="-14" x2="72" y2="-14" stroke="#4a5867" stroke-width="0.6"/>
|
|
289
|
-
<text x="-72" y="0" fill="#8b98a5" font-size="10" font-weight="700"
|
|
289
|
+
<text x="-72" y="0" id="tack-stats-awa-lbl" fill="#8b98a5" font-size="10" font-weight="700"
|
|
290
290
|
font-family="ui-monospace, monospace">AWA</text>
|
|
291
291
|
<text x="-28" y="0" id="tack-stats-awa-before" text-anchor="middle"
|
|
292
292
|
fill="#ffffff" font-size="12" font-weight="800"
|
|
@@ -2651,6 +2651,6 @@
|
|
|
2651
2651
|
</div>
|
|
2652
2652
|
</div>
|
|
2653
2653
|
|
|
2654
|
-
<script src="app.js?v=
|
|
2654
|
+
<script src="app.js?v=Rev516"></script>
|
|
2655
2655
|
</body>
|
|
2656
2656
|
</html>
|
package/public/style.css
CHANGED
|
@@ -1773,8 +1773,10 @@ hr.tune-separator {
|
|
|
1773
1773
|
|
|
1774
1774
|
/* Translucent effect while adjusting - see-through so user can watch heading
|
|
1775
1775
|
converge to target in the Control tab underneath. */
|
|
1776
|
-
|
|
1777
|
-
|
|
1776
|
+
/* Rev515 (Carlos 2026-10-19: "se pone negro, no puedo ver lo que hago"):
|
|
1777
|
+
dim the OTHER rows only. Dimming the whole tab capped the active row
|
|
1778
|
+
at 32 % too (a child cannot be more opaque than its parent). */
|
|
1779
|
+
body.adjusting main #tab-tune .gain-row:not(.active) { opacity: 0.32; transition: opacity 0.1s ease; }
|
|
1778
1780
|
|
|
1779
1781
|
.section-title {
|
|
1780
1782
|
color: var(--fg-dim);
|