signalk-pypilot-newui 3.5.0 → 3.6.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 +23 -0
- package/README.md +6 -2
- package/dist/autowind.js +71 -0
- package/dist/index.js +184 -2
- package/package.json +1 -1
- package/public/app.js +169 -1
- package/public/index.html +13 -2
- package/public/style.css +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.6.0 — 2026-10-19 — AUTO WIND and a logbook button on the rose
|
|
4
|
+
|
|
5
|
+
Rolls up Rev517 → Rev520 on top of 3.5.0. Minor bump: new AUTO WIND
|
|
6
|
+
pseudo-mode. No breaking changes; AUTO WIND is off until chosen.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true wind
|
|
10
|
+
above (hysteresis ±5°, 10 s sustained, at most one switch per 30 s;
|
|
11
|
+
never during a tack, head-to-wind, head-downwind or with the AP off).
|
|
12
|
+
Each side has its own pypilot profile, chosen in the mode window, so
|
|
13
|
+
the true-wind side can be the more reactive one. Runs in the backend
|
|
14
|
+
(works with the visor closed); picking any other mode, or compass from
|
|
15
|
+
the remote, turns it off. Mode button shows "AUTO · REAL / APPARENT".
|
|
16
|
+
Without GPS pypilot offers no true wind: the entry is shown greyed out
|
|
17
|
+
("needs GPS for true wind"), and pypilot's own drop to compass on a
|
|
18
|
+
GPS loss does not turn AUTO WIND off.
|
|
19
|
+
Listed right below compass / GPS in the mode window.
|
|
20
|
+
- **Logbook button**: while a trip is being recorded it opens that trip;
|
|
21
|
+
in port it opens the logbook list.
|
|
22
|
+
- **Logbook button on the rose**: a helm icon on the left, mirroring the
|
|
23
|
+
tide button on the right, opens the Logbook full screen; its x goes
|
|
24
|
+
back to the rose.
|
|
25
|
+
|
|
3
26
|
## 3.5.0 — 2026-10-19 — Fixes from two sea trials, logbook while sailing
|
|
4
27
|
|
|
5
28
|
Rolls up Rev509 → Rev516 on top of 3.4.0. Minor bump: live logbook,
|
package/README.md
CHANGED
|
@@ -131,7 +131,8 @@ up automatically.
|
|
|
131
131
|
13. **Tide at a glance** (with `signalk-mareas-ihm`) — button on the
|
|
132
132
|
rose opens a popup with an interactive tide curve, current height,
|
|
133
133
|
trend, next HW/LW and coefficient, and can open the anchor-watch
|
|
134
|
-
visor inside the rose.
|
|
134
|
+
visor inside the rose. A helm button on the other side opens the
|
|
135
|
+
logbook (the trip in progress while sailing).
|
|
135
136
|
|
|
136
137
|
### Autopilot enhancements — 12 new added functions
|
|
137
138
|
|
|
@@ -142,7 +143,10 @@ up automatically.
|
|
|
142
143
|
original stays one tap away.
|
|
143
144
|
15. **Smart Pilot — auto profile by wind** — switches the pypilot
|
|
144
145
|
profile between Light / Medium / Heavy according to the averaged
|
|
145
|
-
TWS.
|
|
146
|
+
TWS. **AUTO WIND pseudo-mode**: apparent wind below 120° TWA, true
|
|
147
|
+
wind above (with hysteresis), one pypilot profile for each side so
|
|
148
|
+
the true-wind side can be the more reactive one. Needs GPS (pypilot
|
|
149
|
+
only offers true wind with it).
|
|
146
150
|
16. **Smart Pilot — gust strategy** — freeze target, boost D +20%
|
|
147
151
|
for 20 s, temporarily switch to the Heavy profile for 30 s, or
|
|
148
152
|
just warn.
|
package/dist/autowind.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Rev518 (Carlos 2026-10-19): pseudo-mode AUTOWIND. Apparent wind
|
|
3
|
+
// (pypilot "wind") when |TWA| < threshold, true wind ("true wind") when
|
|
4
|
+
// above: downwind the apparent angle swings with boat speed and the
|
|
5
|
+
// pilot chases it (2026-10-19 18:48: +-40 deg in apparent at TWA ~140,
|
|
6
|
+
// steady in true wind). Each side has its own pypilot profile, so the
|
|
7
|
+
// true-wind side can be the more reactive one.
|
|
8
|
+
//
|
|
9
|
+
// Pure decision logic: fed once per second, no I/O.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.AutowindDecider = exports.AUTOWIND_MIN_GAP_MS = exports.AUTOWIND_SUSTAIN_MS = exports.AUTOWIND_HYSTERESIS_DEG = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = void 0;
|
|
12
|
+
exports.desiredSubMode = desiredSubMode;
|
|
13
|
+
exports.AUTOWIND_DEFAULT_THRESHOLD_DEG = 120;
|
|
14
|
+
exports.AUTOWIND_HYSTERESIS_DEG = 5; // +-5 around the threshold
|
|
15
|
+
exports.AUTOWIND_SUSTAIN_MS = 10000; // condition held this long
|
|
16
|
+
exports.AUTOWIND_MIN_GAP_MS = 30000; // between two switches
|
|
17
|
+
/** Side of the threshold the wind is on, with hysteresis; null in the band. */
|
|
18
|
+
function desiredSubMode(twaDeg, thresholdDeg = exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) {
|
|
19
|
+
const a = Math.abs(twaDeg);
|
|
20
|
+
if (a >= thresholdDeg + exports.AUTOWIND_HYSTERESIS_DEG)
|
|
21
|
+
return "true wind";
|
|
22
|
+
if (a <= thresholdDeg - exports.AUTOWIND_HYSTERESIS_DEG)
|
|
23
|
+
return "wind";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
class AutowindDecider {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.pending = null;
|
|
29
|
+
this.pendingSince = null;
|
|
30
|
+
this.lastSwitchMs = null;
|
|
31
|
+
}
|
|
32
|
+
/** Returns the mode to switch to now, or null. */
|
|
33
|
+
tick(i) {
|
|
34
|
+
const cur = String(i.mode || "").toLowerCase();
|
|
35
|
+
const curSub = cur === "true wind" ? "true wind" : cur === "wind" ? "wind" : null;
|
|
36
|
+
if (!i.engaged || i.blocked || typeof i.twaDeg !== "number" || !isFinite(i.twaDeg)) {
|
|
37
|
+
this.pending = null;
|
|
38
|
+
this.pendingSince = null;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const want = desiredSubMode(i.twaDeg, i.thresholdDeg);
|
|
42
|
+
if (want == null || want === curSub) {
|
|
43
|
+
this.pending = null;
|
|
44
|
+
this.pendingSince = null;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (this.pending !== want) {
|
|
48
|
+
this.pending = want;
|
|
49
|
+
this.pendingSince = i.nowMs;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (this.pendingSince != null && i.nowMs - this.pendingSince < exports.AUTOWIND_SUSTAIN_MS)
|
|
53
|
+
return null;
|
|
54
|
+
if (this.lastSwitchMs != null && i.nowMs - this.lastSwitchMs < exports.AUTOWIND_MIN_GAP_MS)
|
|
55
|
+
return null;
|
|
56
|
+
this.pending = null;
|
|
57
|
+
this.pendingSince = null;
|
|
58
|
+
this.lastSwitchMs = i.nowMs;
|
|
59
|
+
return want;
|
|
60
|
+
}
|
|
61
|
+
/** Called when Autowind is switched on: no sustain, no gap. */
|
|
62
|
+
initial(twaDeg, thresholdDeg) {
|
|
63
|
+
this.pending = null;
|
|
64
|
+
this.pendingSince = null;
|
|
65
|
+
if (typeof twaDeg !== "number" || !isFinite(twaDeg))
|
|
66
|
+
return "wind";
|
|
67
|
+
return Math.abs(twaDeg) >= (thresholdDeg ?? exports.AUTOWIND_DEFAULT_THRESHOLD_DEG) ? "true wind" : "wind";
|
|
68
|
+
}
|
|
69
|
+
noteSwitch(nowMs) { this.lastSwitchMs = nowMs; }
|
|
70
|
+
}
|
|
71
|
+
exports.AutowindDecider = AutowindDecider;
|
package/dist/index.js
CHANGED
|
@@ -28,11 +28,12 @@ const profile_metadata_1 = require("./profile-metadata");
|
|
|
28
28
|
const nav_bearing_1 = require("./nav-bearing");
|
|
29
29
|
const session_recorder_1 = require("./session-recorder");
|
|
30
30
|
const steer_watch_1 = require("./steer-watch");
|
|
31
|
+
const autowind_1 = require("./autowind");
|
|
31
32
|
const trip_recorder_1 = require("./trip-recorder");
|
|
32
33
|
const maneuver_trace_1 = require("./maneuver-trace");
|
|
33
34
|
// Rev counter bumped on every build so the user can distinguish deploys
|
|
34
35
|
// from the webapp header (feedback_revision_bump_each_build).
|
|
35
|
-
const PLUGIN_REVISION = "
|
|
36
|
+
const PLUGIN_REVISION = "Rev520";
|
|
36
37
|
// Rev59: read package.json once at load time so /status can report the
|
|
37
38
|
// npm package version alongside the internal Rev counter.
|
|
38
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -168,6 +169,11 @@ module.exports = function (app) {
|
|
|
168
169
|
let _steerRecoveryTotal = 0;
|
|
169
170
|
let _steerRecoveryLastTs = null;
|
|
170
171
|
let _steerRecoveryLastResult = null;
|
|
172
|
+
// Rev518: AUTOWIND state.
|
|
173
|
+
let autowind = new autowind_1.AutowindDecider();
|
|
174
|
+
let _autowindLastSwitchTs = null;
|
|
175
|
+
let _autowindLastReason = null;
|
|
176
|
+
let _autowindOffFamilySince = null;
|
|
171
177
|
// Rev176 (Carlos): debounce the raw `engaged` flag so a rebound
|
|
172
178
|
// caused by a competing client (pypilot_web on another device, a KIP
|
|
173
179
|
// widget cycling, socket races) does not open+close a JSONL every 5 s.
|
|
@@ -722,6 +728,11 @@ module.exports = function (app) {
|
|
|
722
728
|
// land in /alarms/state for the visor banner.
|
|
723
729
|
alarms = new alarms_1.AlarmEngine();
|
|
724
730
|
steerWatch = new steer_watch_1.SteerWatch();
|
|
731
|
+
autowind = new autowind_1.AutowindDecider();
|
|
732
|
+
setTimeout(() => { try {
|
|
733
|
+
_autowindPublish();
|
|
734
|
+
}
|
|
735
|
+
catch { /* silent */ } }, 5000);
|
|
725
736
|
// Rev292 (Carlos, navigating): apply the user's chosen pypilot-
|
|
726
737
|
// disconnect sustain window on start-up, in case pluginOptions
|
|
727
738
|
// already carries a value.
|
|
@@ -943,6 +954,13 @@ module.exports = function (app) {
|
|
|
943
954
|
catch (e) {
|
|
944
955
|
app.debug?.(`[steer-watch] tick: ${e?.message || e}`);
|
|
945
956
|
}
|
|
957
|
+
// Rev518: AUTOWIND (apparent <-> true wind by TWA).
|
|
958
|
+
try {
|
|
959
|
+
_autowindTick();
|
|
960
|
+
}
|
|
961
|
+
catch (e) {
|
|
962
|
+
app.debug?.(`[autowind] tick: ${e?.message || e}`);
|
|
963
|
+
}
|
|
946
964
|
// Rev100: run the alarm engine last so it has every input up to
|
|
947
965
|
// date. Changed rules trigger SK notification deltas.
|
|
948
966
|
try {
|
|
@@ -2526,6 +2544,51 @@ module.exports = function (app) {
|
|
|
2526
2544
|
// Rev167: config read + write for the Smart Pilot card. All the
|
|
2527
2545
|
// toggles live in the visor, not in the SK Admin schema. Writes
|
|
2528
2546
|
// go through app.savePluginOptions so they survive restart.
|
|
2547
|
+
// Rev518: AUTOWIND config + state.
|
|
2548
|
+
router.get("/autowind", (_req, res) => {
|
|
2549
|
+
res.json(_autowindState());
|
|
2550
|
+
});
|
|
2551
|
+
router.post("/autowind", async (req, res) => {
|
|
2552
|
+
if (!props.allowWrites)
|
|
2553
|
+
return res.status(403).json({ error: "allowWrites disabled" });
|
|
2554
|
+
const b = req.body || {};
|
|
2555
|
+
let changed = false;
|
|
2556
|
+
if (typeof b.profileApparent === "string") {
|
|
2557
|
+
props.autowindProfileApparent = b.profileApparent.trim();
|
|
2558
|
+
changed = true;
|
|
2559
|
+
}
|
|
2560
|
+
if (typeof b.profileTrue === "string") {
|
|
2561
|
+
props.autowindProfileTrue = b.profileTrue.trim();
|
|
2562
|
+
changed = true;
|
|
2563
|
+
}
|
|
2564
|
+
if (typeof b.thresholdDeg === "number") {
|
|
2565
|
+
props.autowindThresholdDeg = clampNumber(b.thresholdDeg, 90, 150, autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG);
|
|
2566
|
+
changed = true;
|
|
2567
|
+
}
|
|
2568
|
+
if (changed) {
|
|
2569
|
+
try {
|
|
2570
|
+
app.savePluginOptions?.(props, () => { });
|
|
2571
|
+
}
|
|
2572
|
+
catch { /* silent */ }
|
|
2573
|
+
}
|
|
2574
|
+
if (typeof b.enabled === "boolean") {
|
|
2575
|
+
const was = !!props.autowindEnabled;
|
|
2576
|
+
_autowindSetEnabled(b.enabled, b.enabled ? "selected" : "another mode selected");
|
|
2577
|
+
if (b.enabled && !was) {
|
|
2578
|
+
// Pick the side now, no waiting.
|
|
2579
|
+
const sub = autowind.initial(_autowindTwaDeg(), props.autowindThresholdDeg);
|
|
2580
|
+
await _autowindApply(sub, "AUTOWIND on");
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
// New profile for the side that is active right now: apply it.
|
|
2584
|
+
if (props.autowindEnabled && changed && apProvider) {
|
|
2585
|
+
const mode = String(apProvider.data.mode || "").toLowerCase();
|
|
2586
|
+
if (mode === "wind" || mode === "true wind")
|
|
2587
|
+
_autowindApplyProfile(mode, "profile chosen");
|
|
2588
|
+
}
|
|
2589
|
+
_autowindPublish();
|
|
2590
|
+
res.json(_autowindState());
|
|
2591
|
+
});
|
|
2529
2592
|
router.get("/supervisor/config", (_req, res) => {
|
|
2530
2593
|
res.json({
|
|
2531
2594
|
autoProfileEnabled: !!props.autoProfileEnabled,
|
|
@@ -3821,6 +3884,10 @@ module.exports = function (app) {
|
|
|
3821
3884
|
.includes(options.gustStrategy) ? options.gustStrategy : "off",
|
|
3822
3885
|
autoDisengageOnLostAuthority: options.autoDisengageOnLostAuthority === true,
|
|
3823
3886
|
steerRecoveryEnabled: options.steerRecoveryEnabled === true,
|
|
3887
|
+
autowindEnabled: options.autowindEnabled === true,
|
|
3888
|
+
autowindProfileApparent: typeof options.autowindProfileApparent === "string" ? options.autowindProfileApparent : "",
|
|
3889
|
+
autowindProfileTrue: typeof options.autowindProfileTrue === "string" ? options.autowindProfileTrue : "",
|
|
3890
|
+
autowindThresholdDeg: clampNumber(options.autowindThresholdDeg, 90, 150, autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG),
|
|
3824
3891
|
rollFfGain: clampNumber(options.rollFfGain, 0, 2, 0),
|
|
3825
3892
|
rollFfTauSec: clampNumber(options.rollFfTauSec, 0.5, 30, 3),
|
|
3826
3893
|
rollFfTwaGateDeg: clampNumber(options.rollFfTwaGateDeg, 30, 179, 90),
|
|
@@ -4320,6 +4387,121 @@ module.exports = function (app) {
|
|
|
4320
4387
|
.catch((e) => { _steerRecoveryLastResult = `error: ${e?.message || e}`; })
|
|
4321
4388
|
.finally(() => { _steerRecoveryInFlight = false; });
|
|
4322
4389
|
}
|
|
4390
|
+
// Rev518 (Carlos 2026-10-19): AUTOWIND. See src/autowind.ts.
|
|
4391
|
+
function _autowindState() {
|
|
4392
|
+
const mode = apProvider ? String(apProvider.data.mode || "") : "";
|
|
4393
|
+
return {
|
|
4394
|
+
enabled: !!props.autowindEnabled,
|
|
4395
|
+
subMode: mode === "wind" || mode === "true wind" ? mode : null,
|
|
4396
|
+
profileApparent: props.autowindProfileApparent || "",
|
|
4397
|
+
profileTrue: props.autowindProfileTrue || "",
|
|
4398
|
+
thresholdDeg: props.autowindThresholdDeg ?? autowind_1.AUTOWIND_DEFAULT_THRESHOLD_DEG,
|
|
4399
|
+
lastSwitchTs: _autowindLastSwitchTs,
|
|
4400
|
+
lastReason: _autowindLastReason,
|
|
4401
|
+
};
|
|
4402
|
+
}
|
|
4403
|
+
function _autowindPublish() {
|
|
4404
|
+
try {
|
|
4405
|
+
app.handleMessage(PLUGIN_ID, {
|
|
4406
|
+
context: "vessels." + app.selfId,
|
|
4407
|
+
updates: [{ $source: SOURCE_LABEL, timestamp: new Date().toISOString(), values: [
|
|
4408
|
+
{ path: "steering.autopilot.autowind", value: _autowindState() },
|
|
4409
|
+
] }],
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
catch { /* silent */ }
|
|
4413
|
+
}
|
|
4414
|
+
function _autowindProfileFor(sub) {
|
|
4415
|
+
return (sub === "true wind" ? props.autowindProfileTrue : props.autowindProfileApparent) || "";
|
|
4416
|
+
}
|
|
4417
|
+
function _autowindApplyProfile(sub, reason) {
|
|
4418
|
+
const want = _autowindProfileFor(sub);
|
|
4419
|
+
if (!want || !client || !client.connected)
|
|
4420
|
+
return;
|
|
4421
|
+
if (client.getValues()["profile"] === want)
|
|
4422
|
+
return;
|
|
4423
|
+
try {
|
|
4424
|
+
profileChangeLog.markPlannedWrite(want, "autowind", reason);
|
|
4425
|
+
client.set("profile", want);
|
|
4426
|
+
}
|
|
4427
|
+
catch { /* silent */ }
|
|
4428
|
+
}
|
|
4429
|
+
async function _autowindApply(sub, reason) {
|
|
4430
|
+
if (!apProvider)
|
|
4431
|
+
return;
|
|
4432
|
+
_autowindLastReason = reason;
|
|
4433
|
+
const cur = String(apProvider.data.mode || "").toLowerCase();
|
|
4434
|
+
if (cur !== sub) {
|
|
4435
|
+
// wind <-> true wind share the angle space: pypilot keeps the
|
|
4436
|
+
// heading itself on the mode change, nothing else is written.
|
|
4437
|
+
try {
|
|
4438
|
+
await apProvider.toProviderInterface().setMode(sub, apProvider.deviceId);
|
|
4439
|
+
}
|
|
4440
|
+
catch (e) {
|
|
4441
|
+
app.debug?.(`[autowind] setMode ${sub}: ${e?.message || e}`);
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
_autowindLastSwitchTs = Date.now();
|
|
4445
|
+
autowind.noteSwitch(_autowindLastSwitchTs);
|
|
4446
|
+
}
|
|
4447
|
+
_autowindApplyProfile(sub, reason);
|
|
4448
|
+
// eslint-disable-next-line no-console
|
|
4449
|
+
console.log(`[autowind] ${reason} -> ${sub}${_autowindProfileFor(sub) ? ` / profile ${_autowindProfileFor(sub)}` : ""}`);
|
|
4450
|
+
_autowindPublish();
|
|
4451
|
+
}
|
|
4452
|
+
function _autowindSetEnabled(on, why) {
|
|
4453
|
+
if (!!props.autowindEnabled === on)
|
|
4454
|
+
return;
|
|
4455
|
+
props.autowindEnabled = on;
|
|
4456
|
+
try {
|
|
4457
|
+
app.savePluginOptions?.(props, () => { });
|
|
4458
|
+
}
|
|
4459
|
+
catch { /* silent */ }
|
|
4460
|
+
// eslint-disable-next-line no-console
|
|
4461
|
+
console.log(`[autowind] ${on ? "on" : "off"} (${why})`);
|
|
4462
|
+
_autowindLastReason = why;
|
|
4463
|
+
_autowindPublish();
|
|
4464
|
+
}
|
|
4465
|
+
function _autowindTwaDeg() {
|
|
4466
|
+
const r = _readTwaRad();
|
|
4467
|
+
return r == null ? null : r * 180 / Math.PI;
|
|
4468
|
+
}
|
|
4469
|
+
function _autowindTick() {
|
|
4470
|
+
if (!props.autowindEnabled || !apProvider)
|
|
4471
|
+
return;
|
|
4472
|
+
const mode = String(apProvider.data.mode || "").toLowerCase();
|
|
4473
|
+
const blocked = apProvider.isVirtualTackActive()
|
|
4474
|
+
|| !!(_tackStatePrev && _tackStatePrev !== "none")
|
|
4475
|
+
|| _maneuverPseudoActive;
|
|
4476
|
+
// Sailor (remote, TinyPilot, KIP) moved to compass/gps: AUTOWIND
|
|
4477
|
+
// ends, like picking another mode in the visor. 5 s so a transient
|
|
4478
|
+
// write race never switches it off.
|
|
4479
|
+
// pypilot drops to compass on its own when it loses GPS / true wind
|
|
4480
|
+
// (and returns when it is back): that is not the sailor leaving.
|
|
4481
|
+
const trueWindAvailable = (apProvider.pypilotModes || []).includes("true wind");
|
|
4482
|
+
if (!blocked && trueWindAvailable && mode !== "wind" && mode !== "true wind" && mode !== "") {
|
|
4483
|
+
if (_autowindOffFamilySince == null)
|
|
4484
|
+
_autowindOffFamilySince = Date.now();
|
|
4485
|
+
else if (Date.now() - _autowindOffFamilySince > 5000) {
|
|
4486
|
+
_autowindOffFamilySince = null;
|
|
4487
|
+
_autowindSetEnabled(false, `mode changed to ${mode}`);
|
|
4488
|
+
}
|
|
4489
|
+
return;
|
|
4490
|
+
}
|
|
4491
|
+
_autowindOffFamilySince = null;
|
|
4492
|
+
const want = autowind.tick({
|
|
4493
|
+
nowMs: Date.now(),
|
|
4494
|
+
twaDeg: _autowindTwaDeg(),
|
|
4495
|
+
mode,
|
|
4496
|
+
engaged: !!apProvider.data.engaged,
|
|
4497
|
+
blocked,
|
|
4498
|
+
thresholdDeg: props.autowindThresholdDeg,
|
|
4499
|
+
});
|
|
4500
|
+
if (want) {
|
|
4501
|
+
const twa = _autowindTwaDeg();
|
|
4502
|
+
void _autowindApply(want, `TWA ${twa == null ? "?" : Math.abs(twa).toFixed(0)} deg`);
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4323
4505
|
// Rev164/165/166: supervisor layer. Runs on every historian tick.
|
|
4324
4506
|
// Three independent responsibilities:
|
|
4325
4507
|
// - Auto-profile switch by wind band (only while engaged, opt-in).
|
|
@@ -4412,7 +4594,7 @@ module.exports = function (app) {
|
|
|
4412
4594
|
}
|
|
4413
4595
|
}
|
|
4414
4596
|
// ------------------ Rev164: auto-profile by wind band ------------------
|
|
4415
|
-
if (props.autoProfileEnabled && apProvider && client && historian) {
|
|
4597
|
+
if (props.autoProfileEnabled && !props.autowindEnabled && apProvider && client && historian) {
|
|
4416
4598
|
const engaged = !!apProvider.data.engaged;
|
|
4417
4599
|
if (!engaged) {
|
|
4418
4600
|
_autoProfilePendingBand = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-pypilot-newui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.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
|
@@ -467,6 +467,12 @@
|
|
|
467
467
|
"vt.longJibe.title": "Gybe the long way?",
|
|
468
468
|
"vt.longJibe.body": "The wind is on the other bow: this button turns through the stern ({deg}°). To tack, use the other button.",
|
|
469
469
|
"mode.label.true-wind": "TRUE WIND",
|
|
470
|
+
"mode.label.autowind": "AUTO WIND",
|
|
471
|
+
"mode.label.autowind.long": "AUTO WIND (apparent / true at 120°)",
|
|
472
|
+
"autowind.short": "AUTO",
|
|
473
|
+
"autowind.needsGps": "needs GPS for true wind",
|
|
474
|
+
"autowind.prof.apparent": "Apparent wind",
|
|
475
|
+
"autowind.prof.true": "True wind",
|
|
470
476
|
"mode.label.nav": "WAYPOINT",
|
|
471
477
|
"mode.label.aproado": "HEAD TO WIND",
|
|
472
478
|
"mode.label.empopado": "HEAD DOWNWIND",
|
|
@@ -627,6 +633,7 @@
|
|
|
627
633
|
"info.comp.collision": "AIS collision targets on the rose with name, distance and ACK.",
|
|
628
634
|
"tide.title": "Tide",
|
|
629
635
|
"tide.btn": "Tide",
|
|
636
|
+
"logbook.btn": "Logbook",
|
|
630
637
|
"tide.now": "Now",
|
|
631
638
|
"tide.rising": "Rising",
|
|
632
639
|
"tide.falling": "Falling",
|
|
@@ -1406,6 +1413,12 @@
|
|
|
1406
1413
|
"vt.longJibe.title": "¿Trasluchar por popa?",
|
|
1407
1414
|
"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.",
|
|
1408
1415
|
"mode.label.true-wind": "REAL",
|
|
1416
|
+
"mode.label.autowind": "AUTO VIENTO",
|
|
1417
|
+
"mode.label.autowind.long": "AUTO VIENTO (aparente / real a 120°)",
|
|
1418
|
+
"autowind.short": "AUTO",
|
|
1419
|
+
"autowind.needsGps": "necesita GPS para el viento real",
|
|
1420
|
+
"autowind.prof.apparent": "Viento aparente",
|
|
1421
|
+
"autowind.prof.true": "Viento real",
|
|
1409
1422
|
"mode.label.nav": "RUMBO A WAYPOINT",
|
|
1410
1423
|
"mode.label.aproado": "APROADO",
|
|
1411
1424
|
"mode.label.empopado": "EMPOPADO",
|
|
@@ -1572,6 +1585,7 @@
|
|
|
1572
1585
|
"info.comp.collision": "Barcos AIS con riesgo de colisión en la rosa, con nombre, distancia y ACK.",
|
|
1573
1586
|
"tide.title": "Marea",
|
|
1574
1587
|
"tide.btn": "Marea",
|
|
1588
|
+
"logbook.btn": "Bitácora",
|
|
1575
1589
|
"tide.now": "Ahora",
|
|
1576
1590
|
"tide.rising": "Subiendo",
|
|
1577
1591
|
"tide.falling": "Bajando",
|
|
@@ -2730,6 +2744,8 @@
|
|
|
2730
2744
|
// Rev388 Fase B: backend FSM publishes snapshots here; visor
|
|
2731
2745
|
// opens the read-only HUD + pins mode/target when active.
|
|
2732
2746
|
"steering.autopilot.virtualTack",
|
|
2747
|
+
// Rev518: AUTOWIND state (enabled, sub-mode, profile pair).
|
|
2748
|
+
"steering.autopilot.autowind",
|
|
2733
2749
|
"steering.autopilot.pypilot.ap.tack.state",
|
|
2734
2750
|
"steering.autopilot.pypilot.ap.tack.direction",
|
|
2735
2751
|
"steering.autopilot.pypilot.ap.mode",
|
|
@@ -2943,6 +2959,12 @@
|
|
|
2943
2959
|
state.target = numericOrNull(value);
|
|
2944
2960
|
renderTargetArrow();
|
|
2945
2961
|
break;
|
|
2962
|
+
case "steering.autopilot.autowind": {
|
|
2963
|
+
state.autowind = (value && typeof value === "object") ? value : null;
|
|
2964
|
+
try { _renderModeSelectBtn(); } catch { /* silent */ }
|
|
2965
|
+
try { _rebuildModePopupProfiles(); } catch { /* silent */ }
|
|
2966
|
+
break;
|
|
2967
|
+
}
|
|
2946
2968
|
case "steering.autopilot.virtualTack": {
|
|
2947
2969
|
// Rev388 Fase B: structured snapshot from the backend FSM.
|
|
2948
2970
|
// Open the HUD overlay on first active snapshot, close it on
|
|
@@ -8231,6 +8253,7 @@
|
|
|
8231
8253
|
const modeList = Array.isArray(state.modeList) ? state.modeList : [];
|
|
8232
8254
|
let withPseudo = modeList.includes("aproado") ? modeList : [...modeList, "aproado"];
|
|
8233
8255
|
if (!withPseudo.includes("empopado")) withPseudo = [...withPseudo, "empopado"];
|
|
8256
|
+
if (_autowindAvailable() && !withPseudo.includes("autowind")) withPseudo = _withAutowindAfterCompass(withPseudo);
|
|
8234
8257
|
el.textContent = "";
|
|
8235
8258
|
// Hidden placeholder (Rev250 race-guard carryover).
|
|
8236
8259
|
const ph = document.createElement("option");
|
|
@@ -8267,8 +8290,46 @@
|
|
|
8267
8290
|
return;
|
|
8268
8291
|
}
|
|
8269
8292
|
const m = state.mode || "";
|
|
8293
|
+
// Rev518: AUTOWIND -> "AUTO · REAL" / "AUTO · APARENTE".
|
|
8294
|
+
if (state.autowind && state.autowind.enabled && (m === "wind" || m === "true wind")) {
|
|
8295
|
+
btn.textContent = `${t("autowind.short")} · ${_modeLabel(m)}`;
|
|
8296
|
+
return;
|
|
8297
|
+
}
|
|
8270
8298
|
btn.textContent = m ? _modeLabel(m) : "—";
|
|
8271
8299
|
}
|
|
8300
|
+
// Rev520 (Carlos): AUTO WIND right below compass / GPS.
|
|
8301
|
+
function _withAutowindAfterCompass(list) {
|
|
8302
|
+
const out = list.slice();
|
|
8303
|
+
let at = -1;
|
|
8304
|
+
for (const m of ["compass", "gps"]) { const i = out.indexOf(m); if (i > at) at = i; }
|
|
8305
|
+
out.splice(at + 1, 0, "autowind");
|
|
8306
|
+
return out;
|
|
8307
|
+
}
|
|
8308
|
+
// Rev518: AUTOWIND needs both wind modes from pypilot.
|
|
8309
|
+
function _autowindAvailable() {
|
|
8310
|
+
const ml = Array.isArray(state.modeList) ? state.modeList : [];
|
|
8311
|
+
return ml.includes("wind") && ml.includes("true wind");
|
|
8312
|
+
}
|
|
8313
|
+
async function _autowindPost(body) {
|
|
8314
|
+
try {
|
|
8315
|
+
const r = await skFetch(`/plugins/${PLUGIN_ID}/autowind`, {
|
|
8316
|
+
method: "POST",
|
|
8317
|
+
headers: { "Content-Type": "application/json" },
|
|
8318
|
+
body: JSON.stringify(body),
|
|
8319
|
+
});
|
|
8320
|
+
if (r.ok) {
|
|
8321
|
+
state.autowind = await r.json();
|
|
8322
|
+
_renderModeSelectBtn();
|
|
8323
|
+
_rebuildModePopupProfiles();
|
|
8324
|
+
}
|
|
8325
|
+
} catch (e) { console.warn("[autowind] post", e); }
|
|
8326
|
+
}
|
|
8327
|
+
async function _autowindFetch() {
|
|
8328
|
+
try {
|
|
8329
|
+
const r = await skFetch(`/plugins/${PLUGIN_ID}/autowind`);
|
|
8330
|
+
if (r.ok) { state.autowind = await r.json(); _renderModeSelectBtn(); }
|
|
8331
|
+
} catch { /* silent */ }
|
|
8332
|
+
}
|
|
8272
8333
|
// Rev427 (Carlos QA Rev426, 2026-10-08): "es mejor que se abra una
|
|
8273
8334
|
// ventana grande centrada". Replaced the anchored popup with a
|
|
8274
8335
|
// centered modal (same backdrop pattern as the fork + unlock
|
|
@@ -8282,6 +8343,19 @@
|
|
|
8282
8343
|
// produce visible flashes (Rev428 lesson applied to the new DOM).
|
|
8283
8344
|
const btn = document.getElementById("mode-popup-profile-btn");
|
|
8284
8345
|
const list = document.getElementById("mode-popup-profile-list");
|
|
8346
|
+
// Rev518: with AUTOWIND on, the profile area shows the pair.
|
|
8347
|
+
const aw = document.getElementById("mode-popup-aw");
|
|
8348
|
+
if (aw) {
|
|
8349
|
+
const awOn = !!(state.autowind && state.autowind.enabled);
|
|
8350
|
+
aw.hidden = !awOn;
|
|
8351
|
+
const plainWrap = btn ? btn.closest(".mode-popup-profile-wrap") : null;
|
|
8352
|
+
if (plainWrap) plainWrap.hidden = awOn;
|
|
8353
|
+
const set = (id, txt) => { const el = document.getElementById(id); if (el && el.textContent !== txt) el.textContent = txt; };
|
|
8354
|
+
set("mode-popup-aw-lbl-app", t("autowind.prof.apparent"));
|
|
8355
|
+
set("mode-popup-aw-lbl-true", t("autowind.prof.true"));
|
|
8356
|
+
set("mode-popup-aw-btn-app", (state.autowind && state.autowind.profileApparent) || "—");
|
|
8357
|
+
set("mode-popup-aw-btn-true", (state.autowind && state.autowind.profileTrue) || "—");
|
|
8358
|
+
}
|
|
8285
8359
|
if (!btn && !list) return;
|
|
8286
8360
|
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
|
|
8287
8361
|
if (btn) {
|
|
@@ -8334,6 +8408,13 @@
|
|
|
8334
8408
|
` <ul id="mode-popup-profile-list" class="mode-popup-list mode-popup-profile-list" role="listbox" hidden></ul>` +
|
|
8335
8409
|
` <button type="button" id="mode-popup-profile-btn" class="mode-popup-profile-btn"></button>` +
|
|
8336
8410
|
` </div>` +
|
|
8411
|
+
` <div class="mode-popup-profile-wrap" id="mode-popup-aw" hidden>` +
|
|
8412
|
+
` <div class="mode-popup-group-label" id="mode-popup-aw-lbl-app"></div>` +
|
|
8413
|
+
` <button type="button" id="mode-popup-aw-btn-app" class="mode-popup-profile-btn" data-side="apparent"></button>` +
|
|
8414
|
+
` <div class="mode-popup-group-label" id="mode-popup-aw-lbl-true"></div>` +
|
|
8415
|
+
` <button type="button" id="mode-popup-aw-btn-true" class="mode-popup-profile-btn" data-side="true"></button>` +
|
|
8416
|
+
` <ul id="mode-popup-aw-list" class="mode-popup-list mode-popup-profile-list" role="listbox" hidden></ul>` +
|
|
8417
|
+
` </div>` +
|
|
8337
8418
|
` </div>` +
|
|
8338
8419
|
`</div>`;
|
|
8339
8420
|
document.body.appendChild(m);
|
|
@@ -8350,6 +8431,7 @@
|
|
|
8350
8431
|
const li = ev.target.closest(".mode-popup-item");
|
|
8351
8432
|
if (!li) return;
|
|
8352
8433
|
const chosen = li.dataset.mode;
|
|
8434
|
+
if (!chosen) return; // Rev519: greyed-out entry (AUTO WIND without GPS)
|
|
8353
8435
|
_modeSelectPopupClose();
|
|
8354
8436
|
const nativeSel = document.getElementById("mode-select");
|
|
8355
8437
|
if (nativeSel) {
|
|
@@ -8383,6 +8465,38 @@
|
|
|
8383
8465
|
_modeSelectPopupClose();
|
|
8384
8466
|
});
|
|
8385
8467
|
}
|
|
8468
|
+
// Rev518: AUTOWIND profile pair. Each button opens the shared list
|
|
8469
|
+
// for its side; picking a profile stores it in the backend.
|
|
8470
|
+
const awList = m.querySelector("#mode-popup-aw-list");
|
|
8471
|
+
for (const id of ["#mode-popup-aw-btn-app", "#mode-popup-aw-btn-true"]) {
|
|
8472
|
+
const b = m.querySelector(id);
|
|
8473
|
+
if (!b || !awList) continue;
|
|
8474
|
+
b.addEventListener("click", (ev) => {
|
|
8475
|
+
ev.stopPropagation();
|
|
8476
|
+
const side = b.dataset.side;
|
|
8477
|
+
if (!awList.hidden && awList.dataset.side === side) { awList.hidden = true; return; }
|
|
8478
|
+
awList.dataset.side = side;
|
|
8479
|
+
const cur = side === "true" ? state.autowind?.profileTrue : state.autowind?.profileApparent;
|
|
8480
|
+
awList.textContent = "";
|
|
8481
|
+
for (const pName of (Array.isArray(state.profiles) ? state.profiles : [])) {
|
|
8482
|
+
const li = document.createElement("li");
|
|
8483
|
+
li.className = "mode-popup-item";
|
|
8484
|
+
li.dataset.profile = String(pName);
|
|
8485
|
+
li.textContent = String(pName);
|
|
8486
|
+
if (pName === cur) li.classList.add("active");
|
|
8487
|
+
awList.appendChild(li);
|
|
8488
|
+
}
|
|
8489
|
+
b.after(awList);
|
|
8490
|
+
awList.hidden = false;
|
|
8491
|
+
});
|
|
8492
|
+
}
|
|
8493
|
+
if (awList) awList.addEventListener("click", (ev) => {
|
|
8494
|
+
const li = ev.target.closest(".mode-popup-item");
|
|
8495
|
+
if (!li) return;
|
|
8496
|
+
const key = awList.dataset.side === "true" ? "profileTrue" : "profileApparent";
|
|
8497
|
+
_autowindPost({ [key]: li.dataset.profile });
|
|
8498
|
+
awList.hidden = true;
|
|
8499
|
+
});
|
|
8386
8500
|
return m;
|
|
8387
8501
|
}
|
|
8388
8502
|
function _modeSelectPopupOpen() {
|
|
@@ -8406,18 +8520,29 @@
|
|
|
8406
8520
|
const modeList = Array.isArray(state.modeList) ? state.modeList : [];
|
|
8407
8521
|
let withPseudo = modeList.includes("aproado") ? modeList : [...modeList, "aproado"];
|
|
8408
8522
|
if (!withPseudo.includes("empopado")) withPseudo = [...withPseudo, "empopado"];
|
|
8523
|
+
// Rev519: AUTO WIND always listed when pypilot has wind; greyed out
|
|
8524
|
+
// with the reason when true wind is missing (no GPS).
|
|
8525
|
+
const awNoTrue = !_autowindAvailable() && (Array.isArray(state.modeList) && state.modeList.includes("wind"));
|
|
8526
|
+
if ((_autowindAvailable() || awNoTrue) && !withPseudo.includes("autowind")) withPseudo = _withAutowindAfterCompass(withPseudo);
|
|
8527
|
+
const awOn = !!(state.autowind && state.autowind.enabled);
|
|
8409
8528
|
list.textContent = "";
|
|
8410
8529
|
for (const v of withPseudo) {
|
|
8411
8530
|
const li = document.createElement("li");
|
|
8412
8531
|
li.className = "mode-popup-item";
|
|
8413
8532
|
li.setAttribute("role", "option");
|
|
8414
8533
|
li.dataset.mode = String(v);
|
|
8415
|
-
if (v === state.mode) li.classList.add("active");
|
|
8534
|
+
if (awOn ? v === "autowind" : v === state.mode) li.classList.add("active");
|
|
8416
8535
|
// Rev441 (Carlos 2026-10-10): the modal shows the LONG labels
|
|
8417
8536
|
// ("Viento Aparente", "Viento Real") so wind modes are explicit
|
|
8418
8537
|
// on first open. The button on the bbar still uses the SHORT
|
|
8419
8538
|
// label ("Aparente" / "Real") to fit the narrow chip.
|
|
8420
8539
|
li.textContent = _modeLabel(v, true);
|
|
8540
|
+
if (v === "autowind" && awNoTrue) {
|
|
8541
|
+
li.dataset.mode = "";
|
|
8542
|
+
li.style.opacity = "0.45";
|
|
8543
|
+
li.style.cursor = "default";
|
|
8544
|
+
li.textContent = `${_modeLabel(v)} (${t("autowind.needsGps")})`;
|
|
8545
|
+
}
|
|
8421
8546
|
list.appendChild(li);
|
|
8422
8547
|
}
|
|
8423
8548
|
// Refresh the profile sub-select so its header shows the active one.
|
|
@@ -10507,6 +10632,14 @@
|
|
|
10507
10632
|
$("#mode-select").addEventListener("change", (e) => {
|
|
10508
10633
|
_msCollapse();
|
|
10509
10634
|
const chosen = e.target.value;
|
|
10635
|
+
// Rev518: pseudo-mode AUTOWIND lives in the backend.
|
|
10636
|
+
if (chosen === "autowind") {
|
|
10637
|
+
_autowindPost({ enabled: true });
|
|
10638
|
+
return;
|
|
10639
|
+
}
|
|
10640
|
+
if (state.autowind && state.autowind.enabled) {
|
|
10641
|
+
_autowindPost({ enabled: false });
|
|
10642
|
+
}
|
|
10510
10643
|
// Rev88: pseudo-mode "aproado" is intercepted locally - opens the
|
|
10511
10644
|
// choice modal (bow / stern) and starts the maneuver on confirmation.
|
|
10512
10645
|
if (chosen === "aproado") {
|
|
@@ -11387,6 +11520,40 @@
|
|
|
11387
11520
|
try { new ResizeObserver(placeTideBtn).observe(brCell); } catch { /* fallback bottom */ }
|
|
11388
11521
|
}
|
|
11389
11522
|
}
|
|
11523
|
+
// Rev517 (Carlos 2026-10-19): logbook button on the left, same look
|
|
11524
|
+
// and placement rule as the tide button (above the bottom-left tile).
|
|
11525
|
+
// Opens the Logbook card full screen; its x returns to the rose.
|
|
11526
|
+
const logBtn = document.getElementById("rose-log-btn");
|
|
11527
|
+
if (logBtn) {
|
|
11528
|
+
logBtn.setAttribute("aria-label", t("logbook.btn"));
|
|
11529
|
+
logBtn.setAttribute("title", t("logbook.btn"));
|
|
11530
|
+
logBtn.addEventListener("click", (ev) => {
|
|
11531
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
11532
|
+
// Rev518 (Carlos): sailing -> the trip being recorded; in port ->
|
|
11533
|
+
// the logbook list as before.
|
|
11534
|
+
(async () => {
|
|
11535
|
+
try {
|
|
11536
|
+
const r = await skFetch(`/plugins/${PLUGIN_ID}/trip-recorder/status`);
|
|
11537
|
+
const st = r.ok ? await r.json() : null;
|
|
11538
|
+
if (st && st.recording && st.tripId) { _tripsOpenDetail(st.tripId); return; }
|
|
11539
|
+
} catch { /* fall back to the list */ }
|
|
11540
|
+
const block = document.querySelector('.setup-block[data-block-key="trips"]');
|
|
11541
|
+
if (!block) return;
|
|
11542
|
+
_setupFullscreenOn(block);
|
|
11543
|
+
try { _tripsRefresh(); } catch { /* silent */ }
|
|
11544
|
+
})();
|
|
11545
|
+
});
|
|
11546
|
+
logBtn.addEventListener("dblclick", (ev) => { ev.preventDefault(); ev.stopPropagation(); });
|
|
11547
|
+
const blCell = document.querySelector(".dash-rose .dash-cell.corner-bl");
|
|
11548
|
+
const placeLogBtn = () => {
|
|
11549
|
+
if (!blCell) return;
|
|
11550
|
+
logBtn.style.bottom = `${6 + blCell.offsetHeight + 14}px`;
|
|
11551
|
+
};
|
|
11552
|
+
placeLogBtn();
|
|
11553
|
+
if (blCell && typeof ResizeObserver === "function") {
|
|
11554
|
+
try { new ResizeObserver(placeLogBtn).observe(blCell); } catch { /* fallback bottom */ }
|
|
11555
|
+
}
|
|
11556
|
+
}
|
|
11390
11557
|
// Rev479 (Carlos QA Rev478): "Mareas completo" = the anchor-watch
|
|
11391
11558
|
// visor of signalk-mareas-ihm, opened INSIDE the rose like
|
|
11392
11559
|
// Freeboard (same container classes, ⤢ maximize, × close).
|
|
@@ -16844,6 +17011,7 @@
|
|
|
16844
17011
|
} catch { /* silent */ }
|
|
16845
17012
|
// Rev498: shared visor prefs ride the same 5 s poll.
|
|
16846
17013
|
_vpFetch();
|
|
17014
|
+
_autowindFetch();
|
|
16847
17015
|
}
|
|
16848
17016
|
function _alSeverityIcon(sev) {
|
|
16849
17017
|
return sev === "alarm" ? "!" : sev === "warn" ? "⚠" : "ℹ";
|
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=Rev520" />
|
|
31
31
|
</head>
|
|
32
32
|
<body class="theme-dark">
|
|
33
33
|
<!-- Rev490 (GPU audit 2026-10-15): flag the Raspberry Pi's own screen
|
|
@@ -156,6 +156,17 @@
|
|
|
156
156
|
<path d="M0,22 C5,22 7,8 12,8 S19,24 24,24 S30,14 32,12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
|
|
157
157
|
</svg>
|
|
158
158
|
</button>
|
|
159
|
+
<!-- Rev517 (Carlos 2026-10-19): logbook button, mirror of the tide
|
|
160
|
+
button on the LEFT, just above the bottom-left corner tile.
|
|
161
|
+
Opens the Logbook card full screen without leaving the rose. -->
|
|
162
|
+
<button type="button" class="freeboard-embed-btn rose-corner-btn rose-corner-left" id="rose-log-btn"
|
|
163
|
+
aria-label="Logbook" title="Logbook">
|
|
164
|
+
<svg viewBox="0 0 32 32" aria-hidden="true">
|
|
165
|
+
<circle cx="16" cy="16" r="9.5" fill="none" stroke="currentColor" stroke-width="2.2"/>
|
|
166
|
+
<circle cx="16" cy="16" r="2.6" fill="currentColor"/>
|
|
167
|
+
<path d="M16.0,11.8 L16.0,2.0 M19.0,13.0 L25.9,6.1 M20.2,16.0 L30.0,16.0 M19.0,19.0 L25.9,25.9 M16.0,20.2 L16.0,30.0 M13.0,19.0 L6.1,25.9 M11.8,16.0 L2.0,16.0 M13.0,13.0 L6.1,6.1" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
|
|
168
|
+
</svg>
|
|
169
|
+
</button>
|
|
159
170
|
<!-- Rev432 (Carlos QA Rev431): AIS collision infobox + ACK as
|
|
160
171
|
an HTML absolute div (not SVG) so the ACK click is 100 %
|
|
161
172
|
reliable (no re-render-swallows-tap race), the box always
|
|
@@ -2651,6 +2662,6 @@
|
|
|
2651
2662
|
</div>
|
|
2652
2663
|
</div>
|
|
2653
2664
|
|
|
2654
|
-
<script src="app.js?v=
|
|
2665
|
+
<script src="app.js?v=Rev520"></script>
|
|
2655
2666
|
</body>
|
|
2656
2667
|
</html>
|
package/public/style.css
CHANGED
|
@@ -4377,6 +4377,16 @@ body.pypilot-offline #pypilot-offline-banner { display: block; }
|
|
|
4377
4377
|
overflow: hidden;
|
|
4378
4378
|
}
|
|
4379
4379
|
#rose-tide-btn svg { width: 100%; height: 100%; display: block; }
|
|
4380
|
+
/* Rev517: logbook button = same look as the tide button, on the left. */
|
|
4381
|
+
.rose-corner-btn.rose-corner-left { right: auto; left: 6px; }
|
|
4382
|
+
#rose-log-btn {
|
|
4383
|
+
background: #000;
|
|
4384
|
+
border-color: var(--accent);
|
|
4385
|
+
color: var(--accent);
|
|
4386
|
+
padding: 2px;
|
|
4387
|
+
overflow: hidden;
|
|
4388
|
+
}
|
|
4389
|
+
#rose-log-btn svg { width: 100%; height: 100%; display: block; }
|
|
4380
4390
|
.rose-corner-btn[hidden] { display: none; }
|
|
4381
4391
|
/* Rev486: tide popup content. Popup chrome comes from .popup-box; only
|
|
4382
4392
|
the curve is styled here, with theme variables (UI_STYLE_GUIDE r.10). */
|