signalk-pypilot-newui 2.0.3 → 2.0.4
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 +81 -0
- package/dist/index.js +113 -10
- package/dist/pypilot-client.js +10 -5
- package/package.json +1 -1
- package/public/app.js +255 -55
- package/public/index.html +100 -46
- package/public/style.css +139 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.4 — 2026-08-21 — Rev67
|
|
4
|
+
|
|
5
|
+
Reintroduces (with proper QA) the UI polish + reliability work that was
|
|
6
|
+
withdrawn in 2.0.3 when 2.0.2 hit regressions. Every item below was
|
|
7
|
+
tested on Carlos's Tunatunes Pi Zero W setup before publish.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Restart engine now uses `sv` (runit), not `systemctl`.** piCore does
|
|
12
|
+
not ship systemd; the previous `sudo systemctl restart pypilot pypilot_web`
|
|
13
|
+
was silently falling through to `|| true` on every install. Only the
|
|
14
|
+
`pkill -9` had any effect, and runit's `runsv` was auto-relaunching
|
|
15
|
+
the killed processes into a slightly inconsistent state that
|
|
16
|
+
accumulated zombies + I2C contention until the Pi Zero locked up.
|
|
17
|
+
Fixed to `sudo sv restart /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat`
|
|
18
|
+
with a selective `pkill -9 -f 'python.*pypilot --version'` safety net.
|
|
19
|
+
- **`pypilot --version` preset in Debug console wrapped in `timeout 3`.**
|
|
20
|
+
On piCore that command starts the full pypilot process and never
|
|
21
|
+
exits cleanly; each click was leaving a zombie holding I2C and TCP
|
|
22
|
+
resources. Falls back to reading the packaged version file.
|
|
23
|
+
- **Target diamond drifted after tapping AP on laggy links.** The
|
|
24
|
+
optimistic-target assign now happens INSTANTLY on click (same tick),
|
|
25
|
+
not after the parallel promise resolves - so the diamond snaps to
|
|
26
|
+
the captured heading immediately, without a jump to the stale value
|
|
27
|
+
then back. Nudge also updates `state.target` optimistically so the
|
|
28
|
+
diamond moves in the same tick as the button press.
|
|
29
|
+
- **socket.io auto-reconnect no longer gives up.** `reconnectionAttempts`
|
|
30
|
+
changed from 30 (which could exhaust on a long outage) to Infinity;
|
|
31
|
+
backoff capped at 30 s. Restarts done via `/debug-cmd` (`restart.web`
|
|
32
|
+
/ `reboot.pi`) now schedule an explicit `pause+resume` after the
|
|
33
|
+
operation.
|
|
34
|
+
- **Reconnection watchdog** (every 60 s, was 30 s): if the socket has
|
|
35
|
+
been disconnected > 90 s, force a pause+resume once. Max 3 forced
|
|
36
|
+
reconnects in a rolling 15-min window so a permanently-dead
|
|
37
|
+
pypilot_web is not turned into a local DDoS. Watchdog interval
|
|
38
|
+
loosened per Carlos: "con la Pi Zero debemos ser suuuuuper ratas".
|
|
39
|
+
|
|
40
|
+
### Changed - Emergency section simplified
|
|
41
|
+
|
|
42
|
+
The 3-level escalated restart triad shipped in 2.0.2 has been trimmed
|
|
43
|
+
to 2 levels in Setup -> Emergency: **RESTART pypilot** (level 2, ~10-15 s
|
|
44
|
+
outage) + **reboot Pi** (level 3, ~35-60 s). The old level 1
|
|
45
|
+
(`restart pypilot_web`) rarely helps in practice - when the Pi Zero is
|
|
46
|
+
saturated a full reboot is what actually helps - so it moved to the
|
|
47
|
+
Debug console below as an advanced preset for the ~5 % edge cases.
|
|
48
|
+
|
|
49
|
+
### Compass rose
|
|
50
|
+
|
|
51
|
+
- HDG in a small dark chip that straddles the top of the ring, narrowed
|
|
52
|
+
from 48 to 40 units wide so the enlarged target diamond can peek out
|
|
53
|
+
on all four sides even when target is dead ahead.
|
|
54
|
+
- Target diamond enlarged (28 wide, 30 tall) so its top point asoma
|
|
55
|
+
above the HDG chip and its bottom point below it.
|
|
56
|
+
- Wind arrows both solid (no dashed shaft on TWA), amber AWA (big head)
|
|
57
|
+
+ sea-green (#5ce8c0) TWA (small head), each with its own rotating
|
|
58
|
+
text label placed at very different radii so they never overlap even
|
|
59
|
+
when both arrows point in the same direction.
|
|
60
|
+
- Wind speed at the bottom of the rose now inside a small dark chip
|
|
61
|
+
(same look as the HDG chip on top). Short-tap on the chip OR on a
|
|
62
|
+
corner mapped to "wind" flips AWS/TWS. Long-press opens the popup.
|
|
63
|
+
- Corner options `AWA` and `TWA` now show angle AND matching wind speed
|
|
64
|
+
(AWA/AWS and TWA/TWS) as a combined 2-line cell, consistent with the
|
|
65
|
+
`wind` combo.
|
|
66
|
+
|
|
67
|
+
### Rudder angle indicator in the swipe-hint bar
|
|
68
|
+
|
|
69
|
+
- Default range **±70°** (was ±45°), matching typical TinyPilot configs.
|
|
70
|
+
- Explicit numeric labels at both ends showing the actual range from
|
|
71
|
+
the pypilot catalog.
|
|
72
|
+
- Intermediate tick marks every 10° plus numeric mid-labels at 20°, 40°
|
|
73
|
+
and 60° each side, so the operator reads the rudder angle at a glance.
|
|
74
|
+
|
|
75
|
+
### Internal
|
|
76
|
+
|
|
77
|
+
- Debug console preset `pypilot.version` now `timeout 3 python3 -c
|
|
78
|
+
'import pypilot; print(pypilot.__version__)'` with fallback.
|
|
79
|
+
- Merge-based `_pluginConfigMerge()` helper preserved.
|
|
80
|
+
- Per-cell state for the long-press / short-tap handler on the corner
|
|
81
|
+
dashboard (Rev65's shared timer flags were race-flipping and killing
|
|
82
|
+
the tap toggle intermittently).
|
|
83
|
+
|
|
3
84
|
## 2.0.3 — 2026-08-20 — Rev66 - rollback of 2.0.2
|
|
4
85
|
|
|
5
86
|
Codebase is functionally identical to 2.0.1. Version 2.0.2 introduced
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const scanner_1 = require("./scanner");
|
|
|
6
6
|
const autopilot_provider_1 = require("./autopilot-provider");
|
|
7
7
|
// Rev counter bumped on every build so the user can distinguish deploys
|
|
8
8
|
// from the webapp header (feedback_revision_bump_each_build).
|
|
9
|
-
const PLUGIN_REVISION = "
|
|
9
|
+
const PLUGIN_REVISION = "Rev67";
|
|
10
10
|
// Rev59: read package.json once at load time so /status can report the
|
|
11
11
|
// npm package version alongside the internal Rev counter.
|
|
12
12
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -255,6 +255,17 @@ module.exports = function (app) {
|
|
|
255
255
|
catch { /* defensive */ }
|
|
256
256
|
app._pypilotNewuiKeepAlive = null;
|
|
257
257
|
}
|
|
258
|
+
// Rev66 / 2.0.4: clear the reconnection watchdog interval, otherwise
|
|
259
|
+
// a Disable+Enable cycle in SK Admin would leave the previous
|
|
260
|
+
// watchdog running against a null `client` forever.
|
|
261
|
+
const wd = app._pypilotNewuiWatchdogTimer;
|
|
262
|
+
if (wd) {
|
|
263
|
+
try {
|
|
264
|
+
clearInterval(wd);
|
|
265
|
+
}
|
|
266
|
+
catch { /* defensive */ }
|
|
267
|
+
app._pypilotNewuiWatchdogTimer = null;
|
|
268
|
+
}
|
|
258
269
|
// The SK autopilot API does not expose an unregister; on plugin stop
|
|
259
270
|
// the server drops our provider when it garbage-collects the plugin.
|
|
260
271
|
apProvider = null;
|
|
@@ -269,6 +280,16 @@ module.exports = function (app) {
|
|
|
269
280
|
},
|
|
270
281
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
271
282
|
registerWithRouter: (router) => {
|
|
283
|
+
// Rev66 / 2.0.4: shared "hard reconnect" helper - pauses the socket
|
|
284
|
+
// and re-opens it after a short beat. Used by /restart-pypilot,
|
|
285
|
+
// /debug-cmd (restart.web / reboot.pi presets) and the watchdog.
|
|
286
|
+
const doReconnect = () => {
|
|
287
|
+
try {
|
|
288
|
+
client?.pause();
|
|
289
|
+
setTimeout(() => client?.resume(), 800);
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
292
|
+
};
|
|
272
293
|
router.get("/status", (_req, res) => {
|
|
273
294
|
res.json({
|
|
274
295
|
revision: PLUGIN_REVISION,
|
|
@@ -552,13 +573,29 @@ module.exports = function (app) {
|
|
|
552
573
|
const host = props.host;
|
|
553
574
|
const sshUser = props.sshUser || "tc";
|
|
554
575
|
const sshPassword = props.sshPassword || "";
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
576
|
+
// Rev66 / 2.0.4: TinyPilot runs piCore Linux which uses runit,
|
|
577
|
+
// NOT systemd. Our previous `sudo systemctl restart ...` command
|
|
578
|
+
// was silently falling through to `|| true` on every install
|
|
579
|
+
// because systemctl does not exist. The only step that had any
|
|
580
|
+
// effect was the pkill; runsv then auto-relaunched the killed
|
|
581
|
+
// processes. That inconsistent "kill without a clean start"
|
|
582
|
+
// pattern was accumulating zombies + saturating the Pi Zero's
|
|
583
|
+
// RAM until it hung.
|
|
584
|
+
//
|
|
585
|
+
// The runit-native way: `sudo sv restart /etc/sv/<service>` sends
|
|
586
|
+
// SIGTERM to the child, waits for it to exit, and starts it back
|
|
587
|
+
// up under supervision. Clean by design. We keep pkill as a
|
|
588
|
+
// belt-and-braces safety net in case a python child ignores TERM
|
|
589
|
+
// (some pypilot subprocesses have historically done that).
|
|
590
|
+
const restartCmd = [
|
|
591
|
+
"sudo sv restart /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat 2>&1 || true",
|
|
592
|
+
"sleep 2",
|
|
593
|
+
"sudo pkill -9 -f 'python.*pypilot --version' 2>&1 || true",
|
|
594
|
+
"sleep 1",
|
|
595
|
+
"sudo sv up /etc/sv/pypilot /etc/sv/pypilot_web /etc/sv/pypilot_hat 2>&1 || true",
|
|
596
|
+
].join(" ; ");
|
|
597
|
+
// `doReconnect` is defined at the router scope (Rev66) and reused by
|
|
598
|
+
// /restart-pypilot, /debug-cmd and the watchdog.
|
|
562
599
|
if (!sshPassword) {
|
|
563
600
|
doReconnect();
|
|
564
601
|
return res.status(202).json({
|
|
@@ -649,8 +686,22 @@ module.exports = function (app) {
|
|
|
649
686
|
"top.snapshot": "top -bn1 2>&1 | head -20",
|
|
650
687
|
"uptime": "uptime && cat /proc/loadavg",
|
|
651
688
|
"df": "df -h 2>&1",
|
|
652
|
-
|
|
653
|
-
|
|
689
|
+
// Rev66 / 2.0.4: `pypilot --version` starts the full pypilot
|
|
690
|
+
// process and never exits cleanly on TinyCore + pypilot 2021 -
|
|
691
|
+
// every click of this preset left a python zombie holding I2C
|
|
692
|
+
// and TCP resources. Wrap with `timeout 3` so the exec is killed
|
|
693
|
+
// after 3 s, then fall back to reading the packaged version
|
|
694
|
+
// file. Also `python3 -c 'import pypilot; print(pypilot.__version__)'`
|
|
695
|
+
// as belt-and-braces.
|
|
696
|
+
"pypilot.version": "timeout 3 python3 -c 'import pypilot; print(pypilot.__version__)' 2>&1 || (test -f /home/tc/pypilot/version && cat /home/tc/pypilot/version) || (ls -1 /usr/local/lib/python3.8/site-packages/pypilot-*.dist-info 2>&1 | head -1) || echo 'unknown'",
|
|
697
|
+
// Rev66 / 2.0.4: piCore uses runit, not systemd. `sudo sv
|
|
698
|
+
// restart /etc/sv/pypilot_web` is the correct clean-restart
|
|
699
|
+
// primitive - it TERMs the child, waits, then supervise brings
|
|
700
|
+
// it back up. Scoped to the web unit so the AP core keeps
|
|
701
|
+
// steering. Kept as an advanced preset in the Debug console
|
|
702
|
+
// (the Emergency triad simplified to 2 levels: RESTART pypilot
|
|
703
|
+
// + reboot Pi).
|
|
704
|
+
"restart.web": "sudo sv restart /etc/sv/pypilot_web 2>&1 && echo OK",
|
|
654
705
|
"reboot.pi": "sudo reboot",
|
|
655
706
|
};
|
|
656
707
|
router.post("/debug-cmd", async (req, res) => {
|
|
@@ -722,6 +773,15 @@ module.exports = function (app) {
|
|
|
722
773
|
finish({ ok: false, stdout: e?.message || String(e) });
|
|
723
774
|
}
|
|
724
775
|
});
|
|
776
|
+
// Rev66 / 2.0.4: schedule a hard reconnect after presets that
|
|
777
|
+
// disrupt pypilot_web. Level 1 (restart.web) is quick - 4 s is
|
|
778
|
+
// enough for the socket to notice + wait for pypilot_web to be
|
|
779
|
+
// back up. Level 3 (reboot.pi) needs to survive a ~40 s cold
|
|
780
|
+
// reboot; the watchdog below picks up anything longer than that.
|
|
781
|
+
if (preset === "restart.web")
|
|
782
|
+
setTimeout(doReconnect, 4000);
|
|
783
|
+
if (preset === "reboot.pi")
|
|
784
|
+
setTimeout(doReconnect, 60000);
|
|
725
785
|
// For reboot.pi the ssh session drops as the box goes down, so we
|
|
726
786
|
// report success even on a non-zero exit if we at least connected.
|
|
727
787
|
return res.json({
|
|
@@ -736,6 +796,49 @@ module.exports = function (app) {
|
|
|
736
796
|
return res.status(500).json({ error: e?.message || String(e), preset });
|
|
737
797
|
}
|
|
738
798
|
});
|
|
799
|
+
// Rev66 / 2.0.4: reconnection watchdog. If the socket has been
|
|
800
|
+
// disconnected for > 90 s (much longer than any legitimate
|
|
801
|
+
// pypilot_web restart), force a pause+resume to kick socket.io out
|
|
802
|
+
// of whatever state it got stuck in. Anti-loop: at most 3 forced
|
|
803
|
+
// reconnects in a rolling 15-min window - after that we stop
|
|
804
|
+
// trying automatically so a permanently-dead pypilot_web does
|
|
805
|
+
// not turn into a local DDoS.
|
|
806
|
+
// Rev66 / 2.0.4: watchdog tick every 60 s (was 30 s). The Pi Zero
|
|
807
|
+
// running pypilot is fragile so we minimise every recurring
|
|
808
|
+
// operation - a check that took 30 s worth of wakeups now takes 60 s.
|
|
809
|
+
// Combined with the >90 s "down" threshold, the watchdog only ever
|
|
810
|
+
// acts when the socket has been dead for a while, not on transient
|
|
811
|
+
// Tailscale hiccups.
|
|
812
|
+
let _wdogDisconnectSince = null;
|
|
813
|
+
let _wdogForcedAttempts = []; // timestamps of forced reconnects
|
|
814
|
+
const _wdogTimer = setInterval(() => {
|
|
815
|
+
try {
|
|
816
|
+
if (client?.connected) {
|
|
817
|
+
_wdogDisconnectSince = null;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (_wdogDisconnectSince == null) {
|
|
821
|
+
_wdogDisconnectSince = Date.now();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const downMs = Date.now() - _wdogDisconnectSince;
|
|
825
|
+
if (downMs < 90000)
|
|
826
|
+
return;
|
|
827
|
+
// Prune attempts older than 15 min.
|
|
828
|
+
const cutoff = Date.now() - 15 * 60000;
|
|
829
|
+
_wdogForcedAttempts = _wdogForcedAttempts.filter((t) => t > cutoff);
|
|
830
|
+
if (_wdogForcedAttempts.length >= 3)
|
|
831
|
+
return;
|
|
832
|
+
_wdogForcedAttempts.push(Date.now());
|
|
833
|
+
_wdogDisconnectSince = Date.now(); // reset the timer for the next check
|
|
834
|
+
app.debug(`[watchdog] socket down ${(downMs / 1000) | 0}s - forcing pause+resume (attempt ${_wdogForcedAttempts.length}/3 in 15 min)`);
|
|
835
|
+
doReconnect();
|
|
836
|
+
}
|
|
837
|
+
catch { /* silent */ }
|
|
838
|
+
}, 60000);
|
|
839
|
+
// Store on `app` so plugin.stop() can clear it (defensive - the
|
|
840
|
+
// registerWithRouter closure does not have a stop hook here).
|
|
841
|
+
app._pypilotNewuiWatchdogTimer = _wdogTimer;
|
|
739
842
|
},
|
|
740
843
|
};
|
|
741
844
|
// ---- helpers ----
|
package/dist/pypilot-client.js
CHANGED
|
@@ -55,13 +55,18 @@ class PypilotClient extends events_1.EventEmitter {
|
|
|
55
55
|
this.opts.log("info", `[pypilot] connecting socket.io to ${url}`);
|
|
56
56
|
this.socket = (0, socket_io_client_1.io)(url, {
|
|
57
57
|
transports: ["websocket", "polling"],
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// Rev66 / 2.0.4: infinite reconnect attempts. The old 30-attempt
|
|
59
|
+
// ceiling meant a long-ish outage (say a Pi reboot that takes 45 s +
|
|
60
|
+
// a couple of socket.io backoff cycles) could exhaust the counter
|
|
61
|
+
// and leave the socket "closed forever" - the only recovery was
|
|
62
|
+
// toggling the plugin in SK Admin. With infinite retries plus the
|
|
63
|
+
// 30 s cap on backoff delay, the client keeps trying at most every
|
|
64
|
+
// 30 s until pypilot_web comes back on its own or the watchdog in
|
|
65
|
+
// index.ts triggers a hard pause+resume.
|
|
61
66
|
reconnection: true,
|
|
62
|
-
reconnectionAttempts:
|
|
67
|
+
reconnectionAttempts: Infinity,
|
|
63
68
|
reconnectionDelay: this.opts.reconnectDelayMs,
|
|
64
|
-
reconnectionDelayMax:
|
|
69
|
+
reconnectionDelayMax: 30000,
|
|
65
70
|
timeout: 10000,
|
|
66
71
|
autoConnect: true,
|
|
67
72
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-pypilot-newui",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
package/public/app.js
CHANGED
|
@@ -534,6 +534,20 @@
|
|
|
534
534
|
windAngleTrue: null,
|
|
535
535
|
windSpeedTrue: null,
|
|
536
536
|
sog: null, // m/s
|
|
537
|
+
// Rev65 / 2.0.2 - wind-corner display preference. "awa" or "twa".
|
|
538
|
+
// Initial value derives from the browser-stored preference; if
|
|
539
|
+
// absent, defaults to "awa" and later auto-follows the AP mode
|
|
540
|
+
// (see _syncWindCornerFromMode).
|
|
541
|
+
windCornerShow: (() => {
|
|
542
|
+
try { return localStorage.getItem("pypilot-newui.windCornerShow") || "awa"; }
|
|
543
|
+
catch { return "awa"; }
|
|
544
|
+
})(),
|
|
545
|
+
windCornerUserPinned: (() => {
|
|
546
|
+
// User's manual tap "pins" the choice so mode changes stop
|
|
547
|
+
// overwriting it. Persistent per browser.
|
|
548
|
+
try { return localStorage.getItem("pypilot-newui.windCornerPinned") === "1"; }
|
|
549
|
+
catch { return false; }
|
|
550
|
+
})(),
|
|
537
551
|
};
|
|
538
552
|
|
|
539
553
|
const RAD2DEG = 180 / Math.PI;
|
|
@@ -672,7 +686,7 @@
|
|
|
672
686
|
function applyValue(path, value) {
|
|
673
687
|
switch (path) {
|
|
674
688
|
case "steering.autopilot.state": state.apState = value; break;
|
|
675
|
-
case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); break;
|
|
689
|
+
case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); _syncWindCornerFromMode(value); break;
|
|
676
690
|
case "steering.autopilot.target": state.target = numericOrNull(value); renderTargetArrow(); break;
|
|
677
691
|
case "steering.autopilot.engaged": state.engaged = !!value; renderEngage(); renderTargetArrow(); break;
|
|
678
692
|
case "steering.autopilot.availableActions": state.availableActions = value || []; break;
|
|
@@ -780,9 +794,12 @@
|
|
|
780
794
|
// getComputedTextLength() only works on rendered SVG text nodes.
|
|
781
795
|
// Fall back to a heuristic width if the SVG has not laid out yet
|
|
782
796
|
// (e.g. tab is not visible).
|
|
797
|
+
// Fallback width when SVG has not laid out yet (tab not visible).
|
|
798
|
+
// Rev65: HDG font shrunk from 18 to 14 for the new chip, so the
|
|
799
|
+
// per-char heuristic drops from 12 to 9 units.
|
|
783
800
|
let w;
|
|
784
|
-
try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length *
|
|
785
|
-
if (!isFinite(w) || w <= 0) w = s.length *
|
|
801
|
+
try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 9; }
|
|
802
|
+
if (!isFinite(w) || w <= 0) w = s.length * 9;
|
|
786
803
|
hdgDeg.setAttribute("x", (w / 2 + 1).toFixed(1));
|
|
787
804
|
}
|
|
788
805
|
}
|
|
@@ -799,14 +816,30 @@
|
|
|
799
816
|
const DASH_DATA = {
|
|
800
817
|
none: { label: "-", read: () => "" },
|
|
801
818
|
hdg: { label: "HDG", read: () => state.heading == null ? "---" : Math.round(state.heading * RAD2DEG) + "°" },
|
|
802
|
-
|
|
819
|
+
// Rev66 / 2.0.4 (Carlos): awa/twa muestran ANGULO + VELOCIDAD juntos
|
|
820
|
+
// como el `wind` combo. aws/tws solo velocidad para quien la quiera sola.
|
|
821
|
+
awa: { label: "AWA/AWS", read: () => {
|
|
822
|
+
const a = state.windAngle == null ? "---" : _sgn(state.windAngle * RAD2DEG) + "°";
|
|
823
|
+
const s = state.windSpeed == null ? "---" : (state.windSpeed * 1.94384).toFixed(1) + " kn";
|
|
824
|
+
return { value: a, sub: s };
|
|
825
|
+
} },
|
|
803
826
|
aws: { label: "AWS", read: () => state.windSpeed == null ? "---" : (state.windSpeed * 1.94384).toFixed(1) + " kn" },
|
|
804
|
-
twa: { label: "TWA", read: () =>
|
|
827
|
+
twa: { label: "TWA/TWS", read: () => {
|
|
828
|
+
const a = state.windAngleTrue == null ? "---" : _sgn(state.windAngleTrue * RAD2DEG) + "°";
|
|
829
|
+
const s = state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn";
|
|
830
|
+
return { value: a, sub: s };
|
|
831
|
+
} },
|
|
805
832
|
tws: { label: "TWS", read: () => state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn" },
|
|
806
833
|
twd: { label: "TWD", read: () => state.windDirTrue == null ? "---" : Math.round(state.windDirTrue * RAD2DEG) + "°" },
|
|
807
|
-
|
|
834
|
+
// Rev65 / 2.0.2: the "wind" corner now honours a user-toggleable
|
|
835
|
+
// preference (state.windCornerShow: "awa" or "twa"), overridable by
|
|
836
|
+
// a short tap on the corner. When the AP mode changes TO true-wind,
|
|
837
|
+
// the preference auto-flips to TWA/TWS; when it changes AWAY from
|
|
838
|
+
// true-wind, it flips back to AWA/AWS. Manual taps after that stick
|
|
839
|
+
// until the mode flips again (see _syncWindCornerFromMode).
|
|
840
|
+
wind: { label: () => (state.windCornerShow === "twa" ? "TWA/TWS" : "AWA/AWS"),
|
|
808
841
|
read: () => {
|
|
809
|
-
const useTrue =
|
|
842
|
+
const useTrue = state.windCornerShow === "twa";
|
|
810
843
|
const ang = useTrue ? state.windAngleTrue : state.windAngle;
|
|
811
844
|
const spd = useTrue ? state.windSpeedTrue : state.windSpeed;
|
|
812
845
|
const a = ang == null ? "---" : _sgn(ang * RAD2DEG) + "°";
|
|
@@ -885,6 +918,32 @@
|
|
|
885
918
|
});
|
|
886
919
|
}
|
|
887
920
|
_refreshCustomDashEntries();
|
|
921
|
+
// Rev65 / 2.0.2: short tap on the "wind" corner flips the preference
|
|
922
|
+
// between AWA/AWS and TWA/TWS, and pins it so mode auto-changes stop
|
|
923
|
+
// overwriting it until the user opts back into auto-follow.
|
|
924
|
+
function _toggleWindCornerShow() {
|
|
925
|
+
state.windCornerShow = (state.windCornerShow === "twa") ? "awa" : "twa";
|
|
926
|
+
state.windCornerUserPinned = true;
|
|
927
|
+
try {
|
|
928
|
+
localStorage.setItem("pypilot-newui.windCornerShow", state.windCornerShow);
|
|
929
|
+
localStorage.setItem("pypilot-newui.windCornerPinned", "1");
|
|
930
|
+
} catch {}
|
|
931
|
+
_dashRenderCorners();
|
|
932
|
+
renderWindRose();
|
|
933
|
+
}
|
|
934
|
+
// Called from applyValue whenever the AP mode changes. If the user has
|
|
935
|
+
// not pinned the wind-corner preference, follow the mode: true-wind
|
|
936
|
+
// modes show TWA/TWS, everything else shows AWA/AWS.
|
|
937
|
+
function _syncWindCornerFromMode(mode) {
|
|
938
|
+
if (state.windCornerUserPinned) return;
|
|
939
|
+
const wantTrue = String(mode || "").toLowerCase().includes("true");
|
|
940
|
+
const next = wantTrue ? "twa" : "awa";
|
|
941
|
+
if (state.windCornerShow === next) return;
|
|
942
|
+
state.windCornerShow = next;
|
|
943
|
+
try { localStorage.setItem("pypilot-newui.windCornerShow", next); } catch {}
|
|
944
|
+
_dashRenderCorners();
|
|
945
|
+
renderWindRose();
|
|
946
|
+
}
|
|
888
947
|
function _sgn(n) { return (n > 0 ? "+" : "") + Math.round(n); }
|
|
889
948
|
function _dashLoadCfg() {
|
|
890
949
|
try {
|
|
@@ -1062,9 +1121,10 @@
|
|
|
1062
1121
|
}
|
|
1063
1122
|
|
|
1064
1123
|
// SVG compass rose. The card (cardinals + ticks) rotates so N points to
|
|
1065
|
-
// magnetic north regardless of boat heading; the boat is fixed pointing
|
|
1066
|
-
// TWO independent wind arrows (AWA amber solid, TWA
|
|
1067
|
-
// to their respective angles; wedges are boat-fixed helm guides
|
|
1124
|
+
// magnetic north regardless of boat heading; the boat is fixed pointing
|
|
1125
|
+
// up; TWO independent wind arrows (AWA amber solid, TWA sea-green solid)
|
|
1126
|
+
// rotate to their respective angles; wedges are boat-fixed helm guides;
|
|
1127
|
+
// the bottom AWS/TWS readout follows the user's wind-corner preference.
|
|
1068
1128
|
function renderWindRose() {
|
|
1069
1129
|
const card = document.getElementById("rose-card");
|
|
1070
1130
|
if (card && state.heading != null) {
|
|
@@ -1090,22 +1150,29 @@
|
|
|
1090
1150
|
}
|
|
1091
1151
|
}
|
|
1092
1152
|
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1153
|
+
// Rev65 / 2.0.2: bottom AWS/TWS readout now uses TWO separate text
|
|
1154
|
+
// nodes (small dim label on top, big white number below) and follows
|
|
1155
|
+
// `state.windCornerShow` so a tap on the wind corner also swaps the
|
|
1156
|
+
// readout at the bottom of the rose. Falls back gracefully if the
|
|
1157
|
+
// chosen source has no value yet.
|
|
1158
|
+
const useTrue = state.windCornerShow === "twa";
|
|
1159
|
+
const primarySpd = useTrue ? state.windSpeedTrue : state.windSpeed;
|
|
1160
|
+
const primaryTag = useTrue ? "TWS" : "AWS";
|
|
1161
|
+
let windSpeed = primarySpd, tag = primaryTag;
|
|
1162
|
+
if (windSpeed == null) {
|
|
1163
|
+
// Fall back to the other source so the number is not "---" when only
|
|
1164
|
+
// one wind flavour is available (typical on boats without SOG).
|
|
1165
|
+
const altSpd = useTrue ? state.windSpeed : state.windSpeedTrue;
|
|
1166
|
+
const altTag = useTrue ? "AWS" : "TWS";
|
|
1167
|
+
if (altSpd != null) { windSpeed = altSpd; tag = altTag; }
|
|
1168
|
+
}
|
|
1169
|
+
const speedLabel = document.getElementById("rose-speed-label");
|
|
1170
|
+
const speedText = document.getElementById("rose-speed");
|
|
1171
|
+
if (speedLabel) speedLabel.textContent = windSpeed == null ? "" : tag;
|
|
1105
1172
|
if (speedText) {
|
|
1106
1173
|
speedText.textContent = windSpeed == null
|
|
1107
1174
|
? "--- kn"
|
|
1108
|
-
: (windSpeed * 1.94384).toFixed(1) + " kn"
|
|
1175
|
+
: (windSpeed * 1.94384).toFixed(1) + " kn";
|
|
1109
1176
|
}
|
|
1110
1177
|
}
|
|
1111
1178
|
|
|
@@ -1199,15 +1266,73 @@
|
|
|
1199
1266
|
}
|
|
1200
1267
|
}
|
|
1201
1268
|
|
|
1269
|
+
// Rev66 / 2.0.4 - cache to avoid repainting ticks every rudder delta.
|
|
1270
|
+
let _lastRudderRange = null;
|
|
1271
|
+
function _renderRudderScale(rangeDeg) {
|
|
1272
|
+
const ticksBox = $("#rudder-ticks");
|
|
1273
|
+
if (!ticksBox) return;
|
|
1274
|
+
ticksBox.innerHTML = "";
|
|
1275
|
+
// Ticks cada 10 deg, majors + labels cada 20 deg. Include 0 in the
|
|
1276
|
+
// middle. Sign of the number tells the side.
|
|
1277
|
+
const step = 10;
|
|
1278
|
+
for (let d = -Math.round(rangeDeg); d <= Math.round(rangeDeg); d += step) {
|
|
1279
|
+
const pct = 50 + (d / rangeDeg) * 50;
|
|
1280
|
+
const tick = document.createElement("div");
|
|
1281
|
+
tick.className = (d % 20 === 0) ? "rt major" : "rt";
|
|
1282
|
+
tick.style.left = pct.toFixed(2) + "%";
|
|
1283
|
+
ticksBox.appendChild(tick);
|
|
1284
|
+
// Numeric labels every 20 deg, but skip the extremes (already shown
|
|
1285
|
+
// by the left/right end labels) and skip 0 (would clash with the
|
|
1286
|
+
// yellow nav-toggle triangle in the middle).
|
|
1287
|
+
if (d % 20 === 0 && d !== 0 && Math.abs(d) < rangeDeg - 2) {
|
|
1288
|
+
const label = document.createElement("div");
|
|
1289
|
+
label.className = "rl";
|
|
1290
|
+
label.style.left = pct.toFixed(2) + "%";
|
|
1291
|
+
label.textContent = (d > 0 ? "+" : "") + d;
|
|
1292
|
+
ticksBox.appendChild(label);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1202
1296
|
function renderRudder(rad) {
|
|
1203
|
-
|
|
1204
|
-
|
|
1297
|
+
// Rev66 / 2.0.4: update the rudder-range labels FIRST so they show
|
|
1298
|
+
// even before the first rudder delta arrives (no rudder yet ->
|
|
1299
|
+
// marker centred but labels visible). Range from pypilot catalog,
|
|
1300
|
+
// defaults to 70 deg.
|
|
1301
|
+
const rangeDeg = Number(state.values?.["steering.autopilot.pypilot.rudder.range"]) || 70;
|
|
1302
|
+
const lLeft = $("#rudder-label-left");
|
|
1303
|
+
const lRight = $("#rudder-label-right");
|
|
1304
|
+
if (lLeft) lLeft.textContent = "-" + Math.round(rangeDeg);
|
|
1305
|
+
if (lRight) lRight.textContent = "+" + Math.round(rangeDeg);
|
|
1306
|
+
if (rangeDeg !== _lastRudderRange) {
|
|
1307
|
+
_renderRudderScale(rangeDeg);
|
|
1308
|
+
_lastRudderRange = rangeDeg;
|
|
1309
|
+
}
|
|
1310
|
+
// Legacy widget (still hidden by default) - kept in case someone wires it.
|
|
1311
|
+
const w = $("#rudder-widget");
|
|
1312
|
+
if (rad == null) {
|
|
1313
|
+
if (w) w.setAttribute("hidden", "");
|
|
1314
|
+
// Centre the swipe-hint rudder marker so it looks amidships when
|
|
1315
|
+
// data is missing rather than sticking to some old value.
|
|
1316
|
+
const m = $("#rudder-marker");
|
|
1317
|
+
if (m) m.style.left = "50%";
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (w) w.removeAttribute("hidden");
|
|
1205
1321
|
const deg = rad * RAD2DEG;
|
|
1206
|
-
const
|
|
1322
|
+
const pct45 = Math.max(-1, Math.min(1, deg / 45));
|
|
1207
1323
|
const fill = $("#rudder-fill");
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1324
|
+
if (fill) {
|
|
1325
|
+
fill.style.width = Math.abs(pct45 * 50) + "%";
|
|
1326
|
+
fill.style.transform = pct45 < 0 ? "translateX(-100%)" : "translateX(0%)";
|
|
1327
|
+
}
|
|
1328
|
+
const val = $("#rudder-value");
|
|
1329
|
+
if (val) val.textContent = deg.toFixed(1) + " deg";
|
|
1330
|
+
const marker = $("#rudder-marker");
|
|
1331
|
+
if (marker) {
|
|
1332
|
+
const pct = Math.max(-1, Math.min(1, deg / rangeDeg));
|
|
1333
|
+
// pct = -1 -> left edge (0%), 0 -> center (50%), +1 -> right edge (100%).
|
|
1334
|
+
marker.style.left = (50 + pct * 50).toFixed(1) + "%";
|
|
1335
|
+
}
|
|
1211
1336
|
}
|
|
1212
1337
|
|
|
1213
1338
|
// Rev36 helper: build a slider row with nudge buttons -10/-1/+1/+10.
|
|
@@ -1639,6 +1764,13 @@
|
|
|
1639
1764
|
const sign = String(state.mode || "").includes("wind") ? -1 : 1;
|
|
1640
1765
|
const newTargetRad = (state.localTargetRad ?? 0) + sign * nudge * DEG2RAD;
|
|
1641
1766
|
state.localTargetRad = newTargetRad;
|
|
1767
|
+
// Rev66 / 2.0.4: also update state.target optimistically so the
|
|
1768
|
+
// target diamond MOVES IN THE SAME TICK as the nudge, instead of
|
|
1769
|
+
// waiting for the server's delta reply (~200-800 ms on Tailscale).
|
|
1770
|
+
// If the server later rejects the new target the incoming delta
|
|
1771
|
+
// will overwrite this and the diamond re-syncs.
|
|
1772
|
+
state.target = newTargetRad;
|
|
1773
|
+
renderTargetArrow();
|
|
1642
1774
|
apSetTargetRad(newTargetRad);
|
|
1643
1775
|
});
|
|
1644
1776
|
});
|
|
@@ -1667,9 +1799,34 @@
|
|
|
1667
1799
|
_apPending = true;
|
|
1668
1800
|
eng.classList.add("pending");
|
|
1669
1801
|
const wasEngaged = state.engaged;
|
|
1670
|
-
//
|
|
1802
|
+
// Rev65 / 2.0.2: snapshot the heading the USER IS SEEING at the exact
|
|
1803
|
+
// moment of the click, and use that same value for (a) the target we
|
|
1804
|
+
// send to the server, (b) the optimistic local assign, (c) any later
|
|
1805
|
+
// reference in this handler. Previously we re-read state.heading on
|
|
1806
|
+
// each await point - on a laggy Tailscale link (200-800 ms RTT) the
|
|
1807
|
+
// heading could have drifted a few degrees by the time the POST
|
|
1808
|
+
// executed, so we sent one value to the server, assigned another
|
|
1809
|
+
// locally, and the diamond jumped to a third when the real delta
|
|
1810
|
+
// arrived. Carlos's report: "el diamante no se fija bien".
|
|
1811
|
+
const capturedHeading = state.heading;
|
|
1812
|
+
// Optimistic: flip the visual state AND the target reference before
|
|
1813
|
+
// waiting on the network. Rev66 / 2.0.4: without this early assign
|
|
1814
|
+
// the target diamond was painted at the STALE state.target (last
|
|
1815
|
+
// engage's value) until the server's target-set reply arrived
|
|
1816
|
+
// hundreds of ms later, and then jumped to the fresh value. The
|
|
1817
|
+
// user saw the diamond "sale donde le sale de los huevos". Now
|
|
1818
|
+
// the diamond snaps to the captured heading in the same tick as
|
|
1819
|
+
// the click, and if the server rejects the operation we roll
|
|
1820
|
+
// both engaged and target back below.
|
|
1821
|
+
const prevTarget = state.target;
|
|
1822
|
+
const prevLocalTarget = state.localTargetRad;
|
|
1671
1823
|
state.engaged = !wasEngaged;
|
|
1824
|
+
if (!wasEngaged && capturedHeading != null) {
|
|
1825
|
+
state.target = capturedHeading;
|
|
1826
|
+
state.localTargetRad = capturedHeading;
|
|
1827
|
+
}
|
|
1672
1828
|
renderEngage();
|
|
1829
|
+
renderTargetArrow();
|
|
1673
1830
|
let ok = false;
|
|
1674
1831
|
try {
|
|
1675
1832
|
if (wasEngaged) {
|
|
@@ -1680,20 +1837,24 @@
|
|
|
1680
1837
|
// does not serialise 2 round-trips before the AP actually engages.
|
|
1681
1838
|
const [rEng, rTgt] = await Promise.all([
|
|
1682
1839
|
_apRetry(apEngage),
|
|
1683
|
-
|
|
1840
|
+
capturedHeading != null ? _apRetry(() => apSetTargetRad(capturedHeading)) : Promise.resolve({ ok: true }),
|
|
1684
1841
|
]);
|
|
1685
1842
|
ok = !!(rEng && rEng.ok);
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
}
|
|
1843
|
+
// The optimistic assign above already painted the diamond in the
|
|
1844
|
+
// right spot; just re-render in case renderControl needs to
|
|
1845
|
+
// catch up other widgets that read state.target.
|
|
1846
|
+
if (rTgt && rTgt.ok && capturedHeading != null) renderControl();
|
|
1691
1847
|
}
|
|
1692
1848
|
} catch (_) { ok = false; }
|
|
1693
1849
|
if (!ok) {
|
|
1694
|
-
// Revert the optimistic flip so the UI reflects reality.
|
|
1850
|
+
// Revert the optimistic flip so the UI reflects reality. Rev66:
|
|
1851
|
+
// also revert the target snapshot we assigned above so the
|
|
1852
|
+
// diamond does not stay stuck on a target the server rejected.
|
|
1695
1853
|
state.engaged = wasEngaged;
|
|
1854
|
+
state.target = prevTarget;
|
|
1855
|
+
state.localTargetRad = prevLocalTarget;
|
|
1696
1856
|
renderEngage();
|
|
1857
|
+
renderTargetArrow();
|
|
1697
1858
|
eng.classList.add("failed");
|
|
1698
1859
|
setTimeout(() => eng.classList.remove("failed"), 1500);
|
|
1699
1860
|
}
|
|
@@ -2487,36 +2648,74 @@
|
|
|
2487
2648
|
_activateTab(ids[nextIdx], dir === 1 ? "next" : "prev");
|
|
2488
2649
|
});
|
|
2489
2650
|
});
|
|
2490
|
-
// Rev48: long-press
|
|
2491
|
-
//
|
|
2651
|
+
// Rev48/65: long-press opens the corner-selector popup. Short tap on
|
|
2652
|
+
// a corner whose current mapping is "wind" flips the label between
|
|
2653
|
+
// AWA/AWS and TWA/TWS, pinning the user's choice so subsequent AP
|
|
2654
|
+
// mode changes stop auto-flipping it (see _syncWindCornerFromMode).
|
|
2492
2655
|
_dashLoadCfg();
|
|
2493
2656
|
_dashPopulateSelectors();
|
|
2494
2657
|
_dashRenderCorners();
|
|
2495
|
-
|
|
2496
|
-
|
|
2658
|
+
// Rev66 / 2.0.4 - long-press / short-tap handler with PER-CELL state.
|
|
2659
|
+
// The previous Rev65 version leaked shared timer / timestamp / wasLong
|
|
2660
|
+
// flags across all 4 cells, so a touchstart on one cell overwrote the
|
|
2661
|
+
// in-flight state of another. Short-tap detection race-flipped and the
|
|
2662
|
+
// AWS/TWS toggle stopped working reliably. Wrapping the state inside
|
|
2663
|
+
// each cell's forEach closure gives us clean isolation - each cell
|
|
2664
|
+
// owns its own timer and its own last-press timestamp.
|
|
2665
|
+
const attachLongTap = (el, onShortTap) => {
|
|
2666
|
+
let timer = null;
|
|
2667
|
+
let pressStartedAt = 0;
|
|
2668
|
+
let wasLong = false;
|
|
2497
2669
|
const openSelector = (e) => {
|
|
2498
2670
|
e && e.preventDefault();
|
|
2671
|
+
wasLong = true;
|
|
2499
2672
|
_dashPopulateSelectors();
|
|
2500
2673
|
const pop = document.getElementById("dash-help-pop");
|
|
2501
2674
|
if (pop) pop.classList.add("open");
|
|
2502
2675
|
};
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2676
|
+
const startPress = (e) => {
|
|
2677
|
+
pressStartedAt = Date.now();
|
|
2678
|
+
wasLong = false;
|
|
2679
|
+
if (timer) clearTimeout(timer);
|
|
2680
|
+
timer = setTimeout(() => openSelector(e), 500);
|
|
2681
|
+
};
|
|
2682
|
+
const endPress = () => {
|
|
2683
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
2684
|
+
if (wasLong) return;
|
|
2685
|
+
const elapsed = Date.now() - pressStartedAt;
|
|
2686
|
+
if (elapsed >= 500) return;
|
|
2687
|
+
onShortTap();
|
|
2688
|
+
};
|
|
2689
|
+
const cancelPress = () => {
|
|
2690
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
2691
|
+
};
|
|
2692
|
+
el.addEventListener("touchstart", startPress, { passive: true });
|
|
2693
|
+
el.addEventListener("touchend", endPress, { passive: true });
|
|
2694
|
+
el.addEventListener("touchmove", () => { cancelPress(); wasLong = true; }, { passive: true });
|
|
2695
|
+
el.addEventListener("touchcancel", cancelPress, { passive: true });
|
|
2696
|
+
el.addEventListener("contextmenu", openSelector);
|
|
2697
|
+
el.addEventListener("mousedown", (e) => {
|
|
2514
2698
|
if (e.button !== 0) return;
|
|
2515
|
-
|
|
2699
|
+
startPress(e);
|
|
2700
|
+
});
|
|
2701
|
+
el.addEventListener("mouseup", endPress);
|
|
2702
|
+
el.addEventListener("mouseleave", cancelPress);
|
|
2703
|
+
};
|
|
2704
|
+
// 4 corner cells: short-tap on a cell mapped to "wind" toggles AWS/TWS.
|
|
2705
|
+
$$(".dash-rose .dash-cell[data-corner]").forEach((cell) => {
|
|
2706
|
+
attachLongTap(cell, () => {
|
|
2707
|
+
if (_dashCornerCfg[cell.dataset.corner] === "wind") _toggleWindCornerShow();
|
|
2516
2708
|
});
|
|
2517
|
-
cell.addEventListener("mouseup", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
|
|
2518
|
-
cell.addEventListener("mouseleave", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
|
|
2519
2709
|
});
|
|
2710
|
+
// Rev66 / 2.0.4 - Carlos yes, the wind-speed chip below the rose
|
|
2711
|
+
// also acts as a tap target: short-tap on it toggles AWS/TWS.
|
|
2712
|
+
// Long-press behaves like long-press on any corner cell -> opens
|
|
2713
|
+
// the popup.
|
|
2714
|
+
const speedChip = document.getElementById("rose-speed-chip");
|
|
2715
|
+
if (speedChip) {
|
|
2716
|
+
speedChip.style.cursor = "pointer";
|
|
2717
|
+
attachLongTap(speedChip, () => _toggleWindCornerShow());
|
|
2718
|
+
}
|
|
2520
2719
|
// Rev43: horizontal swipe between tabs on mobile/tablet.
|
|
2521
2720
|
// Rules:
|
|
2522
2721
|
// - Swipe is DISABLED when the Ajustes sliders are unlocked. The
|
|
@@ -3009,6 +3208,7 @@
|
|
|
3009
3208
|
applyI18n();
|
|
3010
3209
|
renderEngage();
|
|
3011
3210
|
renderNudgeLabels();
|
|
3211
|
+
renderRudder(null); // Rev66: pinta los labels ±70 aunque no haya rudder aun
|
|
3012
3212
|
|
|
3013
3213
|
// Login modal wiring (Rev17)
|
|
3014
3214
|
const loginSubmit = $("#login-submit");
|
package/public/index.html
CHANGED
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
<div class="dash-cell corner-tr" data-corner="tr"></div>
|
|
72
72
|
<div class="dash-cell corner-bl" data-corner="bl"></div>
|
|
73
73
|
<div class="dash-cell corner-br" data-corner="br"></div>
|
|
74
|
-
<svg id="wind-rose" class="rose-svg" viewBox="-100 -
|
|
74
|
+
<svg id="wind-rose" class="rose-svg" viewBox="-100 -110 200 220" preserveAspectRatio="xMidYMid meet">
|
|
75
75
|
<!-- Outer compass ring -->
|
|
76
76
|
<circle cx="0" cy="0" r="94" fill="none" stroke="#2b3542" stroke-width="2"/>
|
|
77
77
|
<!-- Rev58: sailing zones as PIE SLICES from origin (r=0) out
|
|
@@ -106,10 +106,13 @@
|
|
|
106
106
|
<line x1="0" y1="-94" x2="0" y2="-84" transform="rotate(330)"/>
|
|
107
107
|
</g>
|
|
108
108
|
<!-- 4 cardinals: N accent yellow, ESW dim white. -->
|
|
109
|
-
<text x="0"
|
|
110
|
-
<text x="70" y="0"
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
<text x="0" y="-70" text-anchor="middle" dominant-baseline="middle" fill="#ffd54a" font-size="14" font-weight="900">N</text>
|
|
110
|
+
<text x="70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">E</text>
|
|
111
|
+
<!-- Rev65: S moved inward from y=70 to y=58 so the AWS/TWS
|
|
112
|
+
bottom-of-rose label at y=70 does not overlap it when the
|
|
113
|
+
card sits at heading 0. -->
|
|
114
|
+
<text x="0" y="58" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">S</text>
|
|
115
|
+
<text x="-70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">W</text>
|
|
113
116
|
<!-- 8 numeric labels at 30 deg increments (skip cardinals). -->
|
|
114
117
|
<g fill="#8b98a5" font-size="7" font-weight="700" font-family="ui-monospace, monospace" text-anchor="middle" dominant-baseline="middle">
|
|
115
118
|
<text transform="rotate(30) translate(0,-72)">030</text>
|
|
@@ -122,42 +125,79 @@
|
|
|
122
125
|
<text transform="rotate(330) translate(0,-72)">330</text>
|
|
123
126
|
</g>
|
|
124
127
|
</g>
|
|
125
|
-
<!--
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
<!-- Rev65 / 2.0.2: HDG in a small chip that straddles the top
|
|
129
|
+
rim. The chip's top edge sits above the ring by ~4 units
|
|
130
|
+
(visually "asomando" per Carlos), the bottom edge sits
|
|
131
|
+
just below the ring so the chip clearly reads as an
|
|
132
|
+
overlay on top of the compass, not as a floating badge.
|
|
133
|
+
The chip does NOT push the SVG viewBox up - only the
|
|
134
|
+
painted rect crosses the ring line. Number CENTERED on
|
|
135
|
+
the digits only; degree glyph placed by JS after the
|
|
136
|
+
digits so "9" and "298" both anchor at x=0. -->
|
|
137
|
+
<!-- Rev66 / 2.0.4: chip narrowed from 48 to 40 wide + font
|
|
138
|
+
shrunk from 14 to 12, so the enlarged target diamond
|
|
139
|
+
below can peek out on the LEFT and RIGHT of the chip
|
|
140
|
+
even when the target is dead ahead (rotation 0). -->
|
|
141
|
+
<g id="rose-hdg-chip">
|
|
142
|
+
<rect x="-20" y="-100" width="40" height="16" rx="4" ry="4"
|
|
143
|
+
fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
|
|
144
|
+
<text id="rose-hdg-value" x="0" y="-91" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">---</text>
|
|
145
|
+
<text id="rose-hdg-deg" x="0" y="-91" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="9" font-weight="700">°</text>
|
|
146
|
+
</g>
|
|
132
147
|
<!-- Rev50: boat overhead SVG (mareas-ihm), scaled 0.22 so
|
|
133
148
|
the hull spans ~100 units = 50 % of the 200-unit rose
|
|
134
149
|
diameter. Rotated -90 so bow points up. -->
|
|
135
150
|
<g id="rose-boat" transform="rotate(-90) scale(0.22)">
|
|
136
151
|
<image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
|
|
137
152
|
</g>
|
|
138
|
-
<!--
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
<!-- Rev65/66 / 2.0.4: two independent wind arrows, both solid,
|
|
154
|
+
each labelled AWA / TWA in text that rotates with the arrow
|
|
155
|
+
so it always reads from the head. AWA = amber (big head,
|
|
156
|
+
the wind the crew feels). TWA = sea-green (small head).
|
|
157
|
+
Rev66: labels are placed at very different radii along
|
|
158
|
+
the shaft (AWA near the ring at y=-72, TWA near the boat
|
|
159
|
+
at y=-52) so they never overlap even when both arrows
|
|
160
|
+
point in exactly the same direction. -->
|
|
142
161
|
<g id="rose-wind-arrow" style="display:none">
|
|
143
|
-
<line x1="0" y1="-86" x2="0" y2="-
|
|
144
|
-
<polygon points="0,-
|
|
162
|
+
<line x1="0" y1="-86" x2="0" y2="-32" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
|
|
163
|
+
<polygon points="0,-24 -9,-40 9,-40" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
|
|
164
|
+
<text x="0" y="-72" text-anchor="middle" dominant-baseline="middle"
|
|
165
|
+
fill="#ffd54a" stroke="#3a2600" stroke-width="0.4" paint-order="stroke fill"
|
|
166
|
+
font-size="8" font-weight="900" letter-spacing="1">AWA</text>
|
|
145
167
|
</g>
|
|
146
168
|
<g id="rose-wind-arrow-twa" style="display:none">
|
|
147
|
-
<line x1="0" y1="-86" x2="0" y2="-
|
|
148
|
-
<polygon points="0,-
|
|
169
|
+
<line x1="0" y1="-86" x2="0" y2="-38" stroke="#5ce8c0" stroke-width="2.5" stroke-linecap="round"/>
|
|
170
|
+
<polygon points="0,-32 -6,-42 6,-42" fill="#5ce8c0" stroke="#1a4438" stroke-width="0.6"/>
|
|
171
|
+
<text x="0" y="-52" text-anchor="middle" dominant-baseline="middle"
|
|
172
|
+
fill="#5ce8c0" stroke="#0f2620" stroke-width="0.4" paint-order="stroke fill"
|
|
173
|
+
font-size="7" font-weight="900" letter-spacing="1">TWA</text>
|
|
149
174
|
</g>
|
|
150
|
-
<!--
|
|
151
|
-
|
|
175
|
+
<!-- Rev66 / 2.0.4: cyan diamond enlarged (28 wide, 30 tall).
|
|
176
|
+
With the narrowed HDG chip above (40 wide) the diamond
|
|
177
|
+
asoma por los lados AND por arriba AND por debajo del
|
|
178
|
+
chip even when target is dead ahead. Rotated to
|
|
152
179
|
(target - heading) so it always points where pypilot is
|
|
153
|
-
steering
|
|
180
|
+
steering (compass/gps/nav modes) or to target directly
|
|
181
|
+
(wind modes, target already in boat frame). -->
|
|
154
182
|
<g id="rose-target-arrow" style="display:none">
|
|
155
|
-
<polygon points="0,-
|
|
183
|
+
<polygon points="0,-108 -14,-93 0,-78 14,-93" fill="#4dd4ff" stroke="#003b52" stroke-width="1.5"/>
|
|
184
|
+
</g>
|
|
185
|
+
<!-- Rev66 / 2.0.4: wind speed now inside a small dark chip
|
|
186
|
+
that straddles the bottom of the compass ring, same
|
|
187
|
+
look as the HDG chip on top. Top of the chip sits a
|
|
188
|
+
couple of units INSIDE the ring, bottom asoma below.
|
|
189
|
+
Contains a small dim AWS/TWS label above the number.
|
|
190
|
+
The whole group carries `id=rose-speed-chip` so the
|
|
191
|
+
short-tap toggle (fix #5) can attach a listener to
|
|
192
|
+
the chip itself, not just the corner cells. -->
|
|
193
|
+
<g id="rose-speed-chip">
|
|
194
|
+
<rect x="-32" y="90" width="64" height="22" rx="4" ry="4"
|
|
195
|
+
fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
|
|
196
|
+
<text id="rose-speed-label" x="0" y="96" text-anchor="middle" dominant-baseline="middle"
|
|
197
|
+
fill="#8b98a5" font-size="7" font-weight="700" letter-spacing="1.2"></text>
|
|
198
|
+
<text id="rose-speed" x="0" y="106" text-anchor="middle" dominant-baseline="middle"
|
|
199
|
+
fill="#ffffff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">--- kn</text>
|
|
156
200
|
</g>
|
|
157
|
-
<!-- Rev58: wind speed dropped to y=+82 (bottom rim) to keep
|
|
158
|
-
the boat area clean. Sub-label picks the source with a
|
|
159
|
-
hint (AWS / TWS / both). -->
|
|
160
|
-
<text id="rose-speed" x="0" y="82" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="800">--- kn</text>
|
|
161
201
|
</svg>
|
|
162
202
|
</div>
|
|
163
203
|
</div>
|
|
@@ -251,12 +291,25 @@
|
|
|
251
291
|
<div id="rudder-value" class="value">--</div>
|
|
252
292
|
</div>
|
|
253
293
|
|
|
254
|
-
<!-- Rev53: swipe affordance strip with central triangle toggle
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
294
|
+
<!-- Rev53/65: swipe affordance strip with central triangle toggle
|
|
295
|
+
AND (Rev65 / 2.0.2) a rudder-angle indicator painted on the
|
|
296
|
+
same strip. Track: horizontal gradient from red (port) at the
|
|
297
|
+
left to green (stbd) at the right, on a black band. Marker:
|
|
298
|
+
a thin vertical bar that sits at the exact centre when the
|
|
299
|
+
rudder is amidships and slides left/right proportional to
|
|
300
|
+
`state.rudderAngle / state.rudderRange`. The central yellow
|
|
301
|
+
triangle keeps its nav-toggle role AND visually reads as the
|
|
302
|
+
"zero" reference of the rudder bar. -->
|
|
259
303
|
<div class="swipe-hint" id="swipe-hint" aria-hidden="true">
|
|
304
|
+
<div class="rudder-track"></div>
|
|
305
|
+
<!-- Rev66 / 2.0.4: rudder scale ticks + labels populated by
|
|
306
|
+
renderRudder() based on the actual pypilot rudder.range
|
|
307
|
+
(default 70 deg). Ticks every 10 deg, numeric labels
|
|
308
|
+
every 20 deg (0, 20, 40, 60) plus explicit ends. -->
|
|
309
|
+
<div class="rudder-ticks" id="rudder-ticks"></div>
|
|
310
|
+
<span class="rudder-label rudder-label-left" id="rudder-label-left">-70</span>
|
|
311
|
+
<span class="rudder-label rudder-label-right" id="rudder-label-right">+70</span>
|
|
312
|
+
<div class="rudder-marker" id="rudder-marker" style="left:50%"></div>
|
|
260
313
|
<span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
|
|
261
314
|
<span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
|
|
262
315
|
<button class="nav-toggle" id="nav-toggle" aria-label="Mostrar / ocultar barra de pestañas" title="Mostrar / ocultar barra de pestañas">
|
|
@@ -476,23 +529,20 @@
|
|
|
476
529
|
</div>
|
|
477
530
|
<div id="cfg-ssh-status" class="hint" style="margin-top:6px"></div>
|
|
478
531
|
|
|
479
|
-
<!--
|
|
480
|
-
|
|
532
|
+
<!-- Rev66 / 2.0.4: simplified to 2 levels. `restart pypilot_web`
|
|
533
|
+
(level 1) rarely helps in practice - when the Pi Zero is
|
|
534
|
+
saturated the whole box needs a full reboot. Moved to the
|
|
535
|
+
Debug console below as an advanced preset for the 5 % of
|
|
536
|
+
cases where only the web layer is stuck but the AP core
|
|
537
|
+
keeps steering (rare). -->
|
|
481
538
|
<div class="restart-block" style="margin-top:14px">
|
|
482
|
-
<div class="restart-title" data-i18n="setup.restart.title">Restart / reboot
|
|
483
|
-
<div class="restart-row">
|
|
484
|
-
<button id="btn-restart-web" class="restart-btn level-1" data-preset="restart.web" data-outage-i18n="setup.restart.outage.web">
|
|
485
|
-
<span class="rb-num">1</span>
|
|
486
|
-
<span class="rb-title" data-i18n="setup.restart.web.title">restart pypilot_web</span>
|
|
487
|
-
<span class="rb-sub" data-i18n="setup.restart.web.sub">~3-5 s · AP keeps steering</span>
|
|
488
|
-
</button>
|
|
539
|
+
<div class="restart-title" data-i18n="setup.restart.title">Restart / reboot</div>
|
|
540
|
+
<div class="restart-row two-cols">
|
|
489
541
|
<button id="cli-restart-pypilot" class="restart-btn level-2" data-outage-i18n="setup.restart.outage.pypilot">
|
|
490
|
-
<span class="rb-num">2</span>
|
|
491
542
|
<span class="rb-title" data-i18n="setup.restart.pypilot.title">RESTART pypilot</span>
|
|
492
543
|
<span class="rb-sub" data-i18n="setup.restart.pypilot.sub">~10-15 s · AP drops the heading</span>
|
|
493
544
|
</button>
|
|
494
545
|
<button id="btn-reboot-pi" class="restart-btn level-3" data-preset="reboot.pi" data-outage-i18n="setup.restart.outage.reboot">
|
|
495
|
-
<span class="rb-num">3</span>
|
|
496
546
|
<span class="rb-title" data-i18n="setup.restart.reboot.title">reboot Pi</span>
|
|
497
547
|
<span class="rb-sub" data-i18n="setup.restart.reboot.sub">~35-60 s · full outage</span>
|
|
498
548
|
</button>
|
|
@@ -500,7 +550,7 @@
|
|
|
500
550
|
</div>
|
|
501
551
|
|
|
502
552
|
<div id="cli-restart-status" class="hint" style="margin-top:8px"></div>
|
|
503
|
-
<div class="hint" data-i18n="setup.restart.hint">
|
|
553
|
+
<div class="hint" data-i18n="setup.restart.hint">Both actions use the SSH user + password above and show a helm-manned confirmation before executing. Use RESTART pypilot for the common case; reboot Pi when the TinyPilot has degraded and needs a cold reset. For a lighter web-only restart see the Debug console below.</div>
|
|
504
554
|
<div class="hint" data-i18n="setup.security.hint" style="margin-top:6px">Signal K stores plugin config in plain text at ~/.signalk/plugin-config-data/.</div>
|
|
505
555
|
|
|
506
556
|
<!-- Rev63 / 2.0.0: Debug console. Closed whitelist of preset
|
|
@@ -516,6 +566,10 @@
|
|
|
516
566
|
<button class="ghost debug-preset" data-preset="uptime">uptime</button>
|
|
517
567
|
<button class="ghost debug-preset" data-preset="df">df</button>
|
|
518
568
|
<button class="ghost debug-preset" data-preset="pypilot.version">pypilot --version</button>
|
|
569
|
+
<!-- Rev66 / 2.0.4: moved from the Emergency triad. Rarely
|
|
570
|
+
needed on a healthy Pi; when the box is saturated a full
|
|
571
|
+
reboot is what actually helps. Kept here for edge cases. -->
|
|
572
|
+
<button class="ghost debug-preset" data-preset="restart.web">restart pypilot_web</button>
|
|
519
573
|
</div>
|
|
520
574
|
<textarea id="debug-output" readonly rows="12"
|
|
521
575
|
style="width:100%;background:var(--bg-3);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px;font-family:ui-monospace,monospace;font-size:12px;resize:vertical;"
|
|
@@ -886,6 +940,6 @@
|
|
|
886
940
|
</div>
|
|
887
941
|
|
|
888
942
|
<!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
|
|
889
|
-
<script src="app.js?v=
|
|
943
|
+
<script src="app.js?v=Rev67"></script>
|
|
890
944
|
</body>
|
|
891
945
|
</html>
|
package/public/style.css
CHANGED
|
@@ -904,48 +904,71 @@ body.nav-hidden .nav-toggle .tri { transform: rotate(180deg); }
|
|
|
904
904
|
text-transform: uppercase;
|
|
905
905
|
margin: 0 0 8px;
|
|
906
906
|
}
|
|
907
|
+
/* Rev66 / 2.0.4: default 3 cols for legacy 3-level layouts; the
|
|
908
|
+
Emergency simplified layout in Rev66 uses .two-cols to switch to
|
|
909
|
+
2 cols on desktop. Both collapse to 1 col on narrow phones. */
|
|
907
910
|
.restart-row {
|
|
908
911
|
display: grid;
|
|
909
912
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
910
913
|
gap: 8px;
|
|
911
914
|
}
|
|
912
|
-
|
|
913
|
-
|
|
915
|
+
.restart-row.two-cols {
|
|
916
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
914
917
|
}
|
|
918
|
+
@media (max-width: 640px) {
|
|
919
|
+
.restart-row,
|
|
920
|
+
.restart-row.two-cols { grid-template-columns: 1fr; }
|
|
921
|
+
}
|
|
922
|
+
/* Rev66 / 2.0.4: simple flex column layout - the numbered badge is
|
|
923
|
+
gone in the 2-level Emergency, and the previous grid template still
|
|
924
|
+
assumed a 28 px badge column, which stretched the title into two
|
|
925
|
+
awkward lines with the sub label overlapping. Flex column stacks
|
|
926
|
+
title on top of sub cleanly regardless of button width. */
|
|
915
927
|
.restart-btn {
|
|
916
|
-
display:
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
gap:
|
|
921
|
-
padding:
|
|
928
|
+
display: flex;
|
|
929
|
+
flex-direction: column;
|
|
930
|
+
align-items: center; /* Rev66 fix: text centered per Carlos */
|
|
931
|
+
justify-content: center;
|
|
932
|
+
gap: 4px;
|
|
933
|
+
padding: 14px 16px;
|
|
934
|
+
min-height: 68px;
|
|
922
935
|
border-radius: 10px;
|
|
923
936
|
border: 2px solid var(--border);
|
|
924
937
|
background: var(--bg-3);
|
|
925
938
|
color: var(--fg);
|
|
926
939
|
cursor: pointer;
|
|
927
|
-
text-align:
|
|
940
|
+
text-align: center; /* Rev66 fix: text centered */
|
|
928
941
|
font-family: inherit;
|
|
929
942
|
transition: transform 80ms ease, background 100ms ease, border-color 100ms ease;
|
|
930
943
|
}
|
|
931
944
|
.restart-btn:hover { border-color: var(--fg-dim); }
|
|
932
945
|
.restart-btn:active { transform: scale(0.97); }
|
|
946
|
+
.restart-btn .rb-title {
|
|
947
|
+
font-weight: 800;
|
|
948
|
+
font-size: 15px;
|
|
949
|
+
letter-spacing: 0.5px;
|
|
950
|
+
line-height: 1.2;
|
|
951
|
+
}
|
|
952
|
+
.restart-btn .rb-sub {
|
|
953
|
+
font-size: 12px;
|
|
954
|
+
color: var(--fg-dim);
|
|
955
|
+
line-height: 1.2;
|
|
956
|
+
}
|
|
957
|
+
/* Optional numbered badge - only rendered by markup that still uses
|
|
958
|
+
the old 3-level layout. When present it sits inline before the
|
|
959
|
+
title on the first row via nested flex, without breaking the plain
|
|
960
|
+
layout when absent. */
|
|
933
961
|
.restart-btn .rb-num {
|
|
934
|
-
|
|
935
|
-
width: 28px; height: 28px;
|
|
962
|
+
width: 24px; height: 24px;
|
|
936
963
|
border-radius: 50%;
|
|
937
|
-
display: flex; align-items: center; justify-content: center;
|
|
938
|
-
font-weight: 900; font-size:
|
|
964
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
965
|
+
font-weight: 900; font-size: 13px;
|
|
939
966
|
background: rgba(255, 255, 255, 0.08);
|
|
940
967
|
color: var(--fg);
|
|
968
|
+
margin-right: 6px;
|
|
969
|
+
vertical-align: middle;
|
|
941
970
|
}
|
|
942
|
-
|
|
943
|
-
.restart-btn .rb-sub { font-size: 12px; color: var(--fg-dim); }
|
|
944
|
-
/* Level 1: green tint, safest - AP keeps steering. */
|
|
945
|
-
.restart-btn.level-1 { border-color: rgba(46, 204, 113, 0.55); }
|
|
946
|
-
.restart-btn.level-1 .rb-num { background: rgba(46, 204, 113, 0.22); color: #2ecc71; }
|
|
947
|
-
.restart-btn.level-1:hover { background: rgba(46, 204, 113, 0.10); }
|
|
948
|
-
/* Level 2: amber, brief AP outage. Matches the old RESTART pypilot look. */
|
|
971
|
+
/* Level 2: amber, brief AP outage. */
|
|
949
972
|
.restart-btn.level-2 { border-color: var(--warn, #f37812); }
|
|
950
973
|
.restart-btn.level-2 .rb-num { background: rgba(243, 120, 18, 0.25); color: var(--warn, #f37812); }
|
|
951
974
|
.restart-btn.level-2:hover { background: rgba(243, 120, 18, 0.10); }
|
|
@@ -953,6 +976,10 @@ body.nav-hidden .nav-toggle .tri { transform: rotate(180deg); }
|
|
|
953
976
|
.restart-btn.level-3 { border-color: var(--err, #e74c3c); }
|
|
954
977
|
.restart-btn.level-3 .rb-num { background: rgba(231, 76, 60, 0.30); color: var(--err, #e74c3c); }
|
|
955
978
|
.restart-btn.level-3:hover { background: rgba(231, 76, 60, 0.10); }
|
|
979
|
+
/* Level 1: kept for backwards-compat if some markup uses it. */
|
|
980
|
+
.restart-btn.level-1 { border-color: rgba(46, 204, 113, 0.55); }
|
|
981
|
+
.restart-btn.level-1 .rb-num { background: rgba(46, 204, 113, 0.22); color: #2ecc71; }
|
|
982
|
+
.restart-btn.level-1:hover { background: rgba(46, 204, 113, 0.10); }
|
|
956
983
|
|
|
957
984
|
/* Rev63 - Helm-manned confirmation modal. Same overlay pattern as
|
|
958
985
|
#dash-help-pop but with a danger accent on the confirm button and a
|
|
@@ -1543,13 +1570,15 @@ main { touch-action: pan-y; overflow-x: hidden; }
|
|
|
1543
1570
|
strip with subtle motion cueing that swipe is available. Also acts
|
|
1544
1571
|
as tappable arrows for tab navigation. */
|
|
1545
1572
|
.swipe-hint {
|
|
1573
|
+
position: relative; /* Rev65: anchor for the rudder track + marker */
|
|
1546
1574
|
display: flex;
|
|
1547
1575
|
align-items: center;
|
|
1548
1576
|
justify-content: space-between;
|
|
1549
1577
|
gap: 10px;
|
|
1550
1578
|
margin-top: 10px;
|
|
1551
1579
|
padding: 8px 12px;
|
|
1552
|
-
background:
|
|
1580
|
+
background: #08111c; /* Rev65: solid dark base; the rudder track
|
|
1581
|
+
below paints its own gradient on top. */
|
|
1553
1582
|
border: 1px dashed rgba(55,182,255,0.35);
|
|
1554
1583
|
border-radius: 10px;
|
|
1555
1584
|
color: rgba(255, 255, 255, 0.65);
|
|
@@ -1559,6 +1588,95 @@ main { touch-action: pan-y; overflow-x: hidden; }
|
|
|
1559
1588
|
user-select: none;
|
|
1560
1589
|
overflow: hidden;
|
|
1561
1590
|
}
|
|
1591
|
+
|
|
1592
|
+
/* Rev65 / 2.0.2 - Rudder angle bar painted inside the swipe-hint strip.
|
|
1593
|
+
.rudder-track is a full-width horizontal gradient (red = port left,
|
|
1594
|
+
green = stbd right) with a dim mid-tone so amidships reads as
|
|
1595
|
+
"neutral". The three foreground layers (arrows, nav-toggle triangle,
|
|
1596
|
+
marker) sit above it via z-index. The nav-toggle's own yellow
|
|
1597
|
+
triangle visually stands in for the 0 deg reference. */
|
|
1598
|
+
.swipe-hint .rudder-track {
|
|
1599
|
+
position: absolute;
|
|
1600
|
+
inset: 4px 8px; /* small inset from the strip edge */
|
|
1601
|
+
border-radius: 8px;
|
|
1602
|
+
background: linear-gradient(
|
|
1603
|
+
90deg,
|
|
1604
|
+
rgba(231, 76, 60, 0.65) 0%, /* PORT: red */
|
|
1605
|
+
rgba(231, 76, 60, 0.20) 40%,
|
|
1606
|
+
rgba(46, 46, 46, 0.30) 50%, /* dim mid = amidships */
|
|
1607
|
+
rgba(46, 204, 113, 0.20) 60%,
|
|
1608
|
+
rgba(46, 204, 113, 0.65) 100% /* STBD: green */
|
|
1609
|
+
);
|
|
1610
|
+
z-index: 0;
|
|
1611
|
+
pointer-events: none;
|
|
1612
|
+
}
|
|
1613
|
+
.swipe-hint .rudder-marker {
|
|
1614
|
+
position: absolute;
|
|
1615
|
+
top: 2px;
|
|
1616
|
+
bottom: 2px;
|
|
1617
|
+
width: 4px;
|
|
1618
|
+
margin-left: -2px; /* center on `left` */
|
|
1619
|
+
background: #ffffff;
|
|
1620
|
+
border-radius: 2px;
|
|
1621
|
+
box-shadow: 0 0 6px rgba(255, 255, 255, 0.55);
|
|
1622
|
+
z-index: 1;
|
|
1623
|
+
transition: left 180ms ease-out;
|
|
1624
|
+
pointer-events: none;
|
|
1625
|
+
}
|
|
1626
|
+
/* Rev66 / 2.0.4 - rudder scale labels at both ends of the swipe-hint
|
|
1627
|
+
bar. Small dim numbers indicating the rudder range in degrees. Text
|
|
1628
|
+
populated by renderRudder() with the current pypilot rudder.range
|
|
1629
|
+
(default 70 deg). Pointer-events off so they don't block taps. */
|
|
1630
|
+
.swipe-hint .rudder-label {
|
|
1631
|
+
position: absolute;
|
|
1632
|
+
top: 50%;
|
|
1633
|
+
transform: translateY(-50%);
|
|
1634
|
+
font-size: 10px;
|
|
1635
|
+
font-weight: 700;
|
|
1636
|
+
color: rgba(255, 255, 255, 0.55);
|
|
1637
|
+
z-index: 1;
|
|
1638
|
+
pointer-events: none;
|
|
1639
|
+
padding: 0 6px;
|
|
1640
|
+
font-family: ui-monospace, monospace;
|
|
1641
|
+
letter-spacing: 0.5px;
|
|
1642
|
+
}
|
|
1643
|
+
.swipe-hint .rudder-label-left { left: 6px; }
|
|
1644
|
+
.swipe-hint .rudder-label-right { right: 6px; }
|
|
1645
|
+
/* Rev66 / 2.0.4 - intermediate tick container. renderRudder() fills
|
|
1646
|
+
this with small marks every 10 deg + numeric labels every 20 deg. */
|
|
1647
|
+
.swipe-hint .rudder-ticks {
|
|
1648
|
+
position: absolute;
|
|
1649
|
+
inset: 4px 8px;
|
|
1650
|
+
z-index: 0;
|
|
1651
|
+
pointer-events: none;
|
|
1652
|
+
}
|
|
1653
|
+
.swipe-hint .rudder-ticks .rt {
|
|
1654
|
+
position: absolute;
|
|
1655
|
+
top: 0;
|
|
1656
|
+
bottom: 0;
|
|
1657
|
+
width: 1px;
|
|
1658
|
+
margin-left: -0.5px;
|
|
1659
|
+
background: rgba(255, 255, 255, 0.25);
|
|
1660
|
+
}
|
|
1661
|
+
.swipe-hint .rudder-ticks .rt.major {
|
|
1662
|
+
width: 1px;
|
|
1663
|
+
background: rgba(255, 255, 255, 0.45);
|
|
1664
|
+
}
|
|
1665
|
+
.swipe-hint .rudder-ticks .rl {
|
|
1666
|
+
position: absolute;
|
|
1667
|
+
top: 50%;
|
|
1668
|
+
transform: translate(-50%, -50%);
|
|
1669
|
+
font-size: 9px;
|
|
1670
|
+
font-weight: 700;
|
|
1671
|
+
color: rgba(255, 255, 255, 0.55);
|
|
1672
|
+
font-family: ui-monospace, monospace;
|
|
1673
|
+
pointer-events: none;
|
|
1674
|
+
}
|
|
1675
|
+
.swipe-hint-arrow,
|
|
1676
|
+
.swipe-hint .nav-toggle {
|
|
1677
|
+
position: relative; /* stack above the rudder track */
|
|
1678
|
+
z-index: 2;
|
|
1679
|
+
}
|
|
1562
1680
|
.swipe-hint-arrow {
|
|
1563
1681
|
font-size: 28px;
|
|
1564
1682
|
line-height: 1;
|