signalk-pypilot-newui 3.0.0 → 3.1.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 +123 -0
- package/README.md +31 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/public/app.js +557 -103
- package/public/index.html +55 -13
- package/public/style.css +77 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,128 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.1.0 — 2026-10-10 — Fullscreen auto, configurable bottom chip, heading precision, AIS polish
|
|
4
|
+
|
|
5
|
+
Rolls up Rev439 → Rev449 (11 revs) on top of 3.0.0. Minor bump: a
|
|
6
|
+
family of UX refinements + a couple of safety-oriented fixes;
|
|
7
|
+
backward-compatible with 3.0.0 plugin config and SK paths. The theme
|
|
8
|
+
of the release is **"glance information at a distance"** — the sailor
|
|
9
|
+
mounts the tablet across the cockpit and reads the visor as a true
|
|
10
|
+
instrument, so chip sizes, legibility, precision indicators, and
|
|
11
|
+
fullscreen-by-default all got tightened in this bump.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Fullscreen + Screen Wake Lock auto-on by default** (Rev446/Rev448).
|
|
16
|
+
On the very first user tap, the visor requests browser fullscreen
|
|
17
|
+
on `<html>` and takes a `navigator.wakeLock("screen")` so the
|
|
18
|
+
display never dims nor locks — same UX as YouTube's player. A
|
|
19
|
+
floating ⛶ button top-right toggles both and persists the sailor's
|
|
20
|
+
choice (`localStorage[pypilot-newui.fullscreenAuto]`), so later
|
|
21
|
+
reloads honour "I opted out".
|
|
22
|
+
- **Configurable bottom chip on the rose — up to 4 datapoints
|
|
23
|
+
cycled** (Rev439 → Rev445). Short-tap on the chip under the compass
|
|
24
|
+
cycles through the user-picked set (default: SOG → Depth → %Polar);
|
|
25
|
+
long-press opens a modal where the sailor picks up to 4 datapoints
|
|
26
|
+
from the same catalog as the four configurable corners (base paths +
|
|
27
|
+
performance extras + any custom SK path registered in a corner).
|
|
28
|
+
Persisted in `localStorage[pypilot-newui.roseBottomChipPaths]`.
|
|
29
|
+
Replaces the old "always AWS/TWS mirror" behaviour.
|
|
30
|
+
- **Heading-precision gauge under the compass readout** (Rev443/Rev444).
|
|
31
|
+
Second line inside the `#rose-hdg-chip` shows how far off the pilot
|
|
32
|
+
still is from its own target — the sailor's "glance accuracy
|
|
33
|
+
reading", not the target value itself. Signed `target - current`:
|
|
34
|
+
negative = pilot must correct to port (babor), positive = to
|
|
35
|
+
starboard (estribor). Colour: `|err|<3°` green, `<10°` orange, else
|
|
36
|
+
red. In compass / gps / nav uses heading − target; in wind / aproado
|
|
37
|
+
uses AWA − target; in true wind / empopado uses TWA − target.
|
|
38
|
+
Shows `--` when the AP is OFF (`state.engaged === false`) so a
|
|
39
|
+
stale `state.target` can never mislead the sailor.
|
|
40
|
+
- **Safe-exit to compass after APROADO / EMPOPADO** (Rev440). When
|
|
41
|
+
the user finishes an aproado or empopado without opting in to full
|
|
42
|
+
`restoreAll`, the visor now auto-switches the pilot back to COMPASS
|
|
43
|
+
with the current heading as target. Rationale: leaving the pilot in
|
|
44
|
+
WIND / TRUE WIND after the manoeuvre is less stable for "hold what
|
|
45
|
+
I have now" than COMPASS. Carlos sea-trial 2026-10-10. Falls back
|
|
46
|
+
to no-op if the heading sensor is absent.
|
|
47
|
+
- **AIS target as a tinted top-view hull sprite** (Rev442 → Rev443,
|
|
48
|
+
Rev447 → Rev449). Replaces the plain red triangle with the
|
|
49
|
+
`boat-cenital.svg` sprite (same asset as the own-vessel sprite in
|
|
50
|
+
the rose centre), tinted via SVG `feColorMatrix` filters per state:
|
|
51
|
+
red (alarm, blinking, 150 % scale) · orange (warn, fixed, 100 %) ·
|
|
52
|
+
blue-grey (acked, no blink, 100 %). Bow always points toward the
|
|
53
|
+
self vessel (rotation now `rotate(relDeg + 90)` to match the
|
|
54
|
+
horizontal asset). Alarm sprite blinks; acked stays fully visible
|
|
55
|
+
but muted. Paints as the LAST element inside the rose SVG so it
|
|
56
|
+
stays on top of wind arrows, sectors and HUDs.
|
|
57
|
+
- **Only the 3 most urgent AIS targets painted** (Rev441). When more
|
|
58
|
+
than 3 collision alarms are active, only the 3 lowest-TCPA ones
|
|
59
|
+
render on the rose. The infobox navigator still walks ALL alarms so
|
|
60
|
+
the sailor can scroll through the hidden ones.
|
|
61
|
+
- **Tap on an AIS target opens its data popup** (Rev441). Click
|
|
62
|
+
delegation on `#ais-alarm-layer`: tapping a hull sets
|
|
63
|
+
`_aisInfoboxIdx` to that MMSI and forces the popup open — works for
|
|
64
|
+
both pending and silenced targets. Auto-resets to the newest threat
|
|
65
|
+
after 20 s if no further interaction.
|
|
66
|
+
- **ACK keeps the AIS target arrow on the rose** (Rev441 → Rev442).
|
|
67
|
+
ACK no longer deletes the MMSI from `state.aisAlarms`; the hull
|
|
68
|
+
stays visible (silenced style) until the plugin declares the
|
|
69
|
+
threat gone. Button toggles ACK ↔ ACKED (ES: SILENCIAR ↔
|
|
70
|
+
SILENCIADO), with `disabled` + `.is-acked` styling when the MMSI is
|
|
71
|
+
already acknowledged.
|
|
72
|
+
- **X close button on the AIS infobox** (Rev442). When the sailor
|
|
73
|
+
opened the popup by tapping a hull, the X dismisses it and clears
|
|
74
|
+
the user-pick flag so new threats can take over.
|
|
75
|
+
- **Long "Viento Aparente" / "Viento Real" labels in the mode-selector
|
|
76
|
+
modal** (Rev441). The modal shows the long names so wind modes are
|
|
77
|
+
explicit on first open; the bbar button keeps the short labels
|
|
78
|
+
(APARENTE / REAL) to fit the narrow chip.
|
|
79
|
+
- **Landscape tabbar made compact** (Rev446). `.tab-btn` padding
|
|
80
|
+
`12→4 px`, font-size `14→12 px` inside
|
|
81
|
+
`@media (orientation: landscape)`. Saves ~20 vertical pixels for
|
|
82
|
+
tablets mounted horizontally.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **Heading chip grows UPWARD** (Rev444). The chip rect height
|
|
87
|
+
expands toward `-y` (bottom edge still at `-84`), so the new
|
|
88
|
+
precision line never covers the target diamond that sits just
|
|
89
|
+
below the top of the compass ring.
|
|
90
|
+
- **AWS / TWS sub-line font-size bumped three steps** (Rev443 →
|
|
91
|
+
Rev445). From `clamp(11, 2vw, 16px)` to `clamp(20, 3.8vw, 32px)`
|
|
92
|
+
so the kn number sits halfway between the dim corner label and
|
|
93
|
+
the big angle value when read from the helm.
|
|
94
|
+
- **AIS voice is a short safety nudge, not a report** (Rev441,
|
|
95
|
+
Rev449). The voice line used to spell CPA / TCPA / MMSI, which TTS
|
|
96
|
+
mangled on long numbers. Now it just says *"Alarma AIS, target en
|
|
97
|
+
rumbo de colisión"* / *"AIS alarm, target on collision course"*;
|
|
98
|
+
the data stays visible in the infobox. ES says "AIS" as a word
|
|
99
|
+
(Rev449 — was "A I S" letter-by-letter).
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- **AIS target bearing was 180° off in some geometries** (Rev448 —
|
|
104
|
+
found during 2026-10-10 sea trial). `renderAisAlarms` checked
|
|
105
|
+
`state.position.latitude` but `state.position` carries short keys
|
|
106
|
+
`{lat, lon}` — the test always failed and the code fell back to
|
|
107
|
+
`cpaPositions.self` (= *projected* self position at CPA future
|
|
108
|
+
time). Bearing was then computed between two **future** positions,
|
|
109
|
+
which can be nowhere near the current geometry. The adapter now
|
|
110
|
+
converts `{lat, lon}` → `{latitude, longitude}` once at render
|
|
111
|
+
time, so the hull always paints at the real bearing to the target
|
|
112
|
+
as-of-now.
|
|
113
|
+
- **Signal K server token kept being asked "every once in a while"**
|
|
114
|
+
(Rev446). `handleAuthWrite` wiped the permanent device token on
|
|
115
|
+
the FIRST `401` and kicked off an access-request modal — a single
|
|
116
|
+
transient 401 (SK restart in flight, proxy hiccup, DNS blip) was
|
|
117
|
+
enough to invalidate a perfectly good token. Now requires THREE
|
|
118
|
+
consecutive 401 writes before wiping; one successful write resets
|
|
119
|
+
the counter.
|
|
120
|
+
- **Precision gauge sign convention** (Rev444). First pass used
|
|
121
|
+
`heading − target`; Carlos's convention is "the correction the
|
|
122
|
+
pilot still has to apply" → `target − heading`. Negative therefore
|
|
123
|
+
means *pilot must go to port* and positive *pilot must go to
|
|
124
|
+
starboard*.
|
|
125
|
+
|
|
3
126
|
## 3.0.0 — 2026-10-10 — AIS collision overlay + Freeboard-SK embedded
|
|
4
127
|
|
|
5
128
|
Rolls up Rev431 through Rev438 (8 revs). The headline feature of 3.0.0
|
package/README.md
CHANGED
|
@@ -10,9 +10,36 @@ watches how your autopilot is behaving and quietly suggests how to
|
|
|
10
10
|
sharpen its settings, so you spend more time sailing and less time
|
|
11
11
|
guessing which slider to move.
|
|
12
12
|
|
|
13
|
+
Since **3.0.0** the visor also ships with an **integrated
|
|
14
|
+
chartplotter**: double-tap the compass rose and
|
|
15
|
+
[Freeboard-SK](https://www.npmjs.com/package/@signalk/freeboard-sk)
|
|
16
|
+
opens embedded inside the rose box with every AIS target, your route
|
|
17
|
+
and all overlays — side panels (mode, nudges, AP) stay reachable, no
|
|
18
|
+
tab switching, no re-authentication. **For charts to render you need a
|
|
19
|
+
Signal K chart provider installed and configured in Freeboard-SK**
|
|
20
|
+
(see [Chart provider is required](#chart-provider-is-required-for-the-embedded-plotter)
|
|
21
|
+
below).
|
|
22
|
+
|
|
13
23
|
Runs alongside [`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)
|
|
14
24
|
by Panaaj — see [Working alongside pypilot-autopilot-provider](#working-alongside-pypilot-autopilot-provider).
|
|
15
25
|
|
|
26
|
+
## Chart provider is required for the embedded plotter
|
|
27
|
+
|
|
28
|
+
The chartplotter embedded in the rose uses Freeboard-SK, which itself
|
|
29
|
+
is only a renderer — it needs a Signal K **chart provider** plugin to
|
|
30
|
+
know where to fetch tiles from. Any of the standard providers works:
|
|
31
|
+
|
|
32
|
+
- **[`@signalk/charts-plugin`](https://www.npmjs.com/package/@signalk/charts-plugin)**
|
|
33
|
+
— serves local MBTiles / S57 / XYZ chart sets you drop into
|
|
34
|
+
`~/.signalk/charts/` (OpenCPN-style). Recommended default.
|
|
35
|
+
- **[`signalk-chart-tiler`](https://www.npmjs.com/package/signalk-chart-tiler)**
|
|
36
|
+
or any other upstream raster/vector provider.
|
|
37
|
+
|
|
38
|
+
Without a provider, Freeboard-SK will open embedded in the rose but
|
|
39
|
+
the map area will stay empty. Install one from the SK App Store, add
|
|
40
|
+
your chart sources, and the chartplotter inside pypilot-newui lights
|
|
41
|
+
up automatically.
|
|
42
|
+
|
|
16
43
|
## Screenshots
|
|
17
44
|
|
|
18
45
|

|
|
@@ -247,7 +274,10 @@ on its own), but installing them unlocks the features listed.
|
|
|
247
274
|
— Double-tap the compass rose to open Freeboard-SK embedded inside
|
|
248
275
|
the rose box (side panels stay visible and usable). Lets you drop
|
|
249
276
|
from the autopilot view to the full chart with every AIS target in
|
|
250
|
-
one tap, without leaving the pypilot-newui session.
|
|
277
|
+
one tap, without leaving the pypilot-newui session. **Pair with a
|
|
278
|
+
Signal K chart provider (e.g. `@signalk/charts-plugin`) or the chart
|
|
279
|
+
area will stay empty** — see
|
|
280
|
+
[Chart provider is required](#chart-provider-is-required-for-the-embedded-plotter).
|
|
251
281
|
See `docs/_feature_ais_overlay.md`.
|
|
252
282
|
|
|
253
283
|
- **[`signalk-collision-alerts`](https://www.npmjs.com/package/signalk-collision-alerts)**
|
package/dist/index.js
CHANGED
|
@@ -28,7 +28,7 @@ const trip_recorder_1 = require("./trip-recorder");
|
|
|
28
28
|
const maneuver_trace_1 = require("./maneuver-trace");
|
|
29
29
|
// Rev counter bumped on every build so the user can distinguish deploys
|
|
30
30
|
// from the webapp header (feedback_revision_bump_each_build).
|
|
31
|
-
const PLUGIN_REVISION = "
|
|
31
|
+
const PLUGIN_REVISION = "Rev449";
|
|
32
32
|
// Rev59: read package.json once at load time so /status can report the
|
|
33
33
|
// npm package version alongside the internal Rev counter.
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-pypilot-newui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.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
|
"scripts": {
|
package/public/app.js
CHANGED
|
@@ -591,6 +591,9 @@
|
|
|
591
591
|
"ais.objetivo": "target",
|
|
592
592
|
"ais.voice.alarm": "AIS alarm",
|
|
593
593
|
"ais.voice.warn": "AIS warning",
|
|
594
|
+
"ais.voice.collisionCourse": "target on collision course",
|
|
595
|
+
"ais.ack": "ACK",
|
|
596
|
+
"ais.acked": "ACKED",
|
|
594
597
|
"ais.voice.at": "at",
|
|
595
598
|
"ais.voice.metres": "metres",
|
|
596
599
|
"ais.voice.miles": "miles",
|
|
@@ -1466,8 +1469,11 @@
|
|
|
1466
1469
|
"dash.overlay.ais.config": "Abrir ajustes del plugin",
|
|
1467
1470
|
"dash.overlay.ais.voice": "Aviso de voz",
|
|
1468
1471
|
"ais.objetivo": "objetivo",
|
|
1469
|
-
"ais.voice.alarm": "Alarma
|
|
1470
|
-
"ais.voice.warn": "Aviso
|
|
1472
|
+
"ais.voice.alarm": "Alarma AIS",
|
|
1473
|
+
"ais.voice.warn": "Aviso AIS",
|
|
1474
|
+
"ais.voice.collisionCourse": "target en rumbo de colisión",
|
|
1475
|
+
"ais.ack": "SILENCIAR",
|
|
1476
|
+
"ais.acked": "SILENCIADO",
|
|
1471
1477
|
"ais.voice.at": "a",
|
|
1472
1478
|
"ais.voice.metres": "metros",
|
|
1473
1479
|
"ais.voice.miles": "millas",
|
|
@@ -2421,6 +2427,99 @@
|
|
|
2421
2427
|
_netHealthPing();
|
|
2422
2428
|
setInterval(_netHealthPing, 5000);
|
|
2423
2429
|
}
|
|
2430
|
+
// Rev446 (Carlos): "pantalla completa en el navegador y hacer que la
|
|
2431
|
+
// pantalla ni se apague ni se atenúe, como YouTube". One floating
|
|
2432
|
+
// button:
|
|
2433
|
+
// • enter → requestFullscreen on <html> + Screen Wake Lock.
|
|
2434
|
+
// • exit → exitFullscreen + release the lock (browser also releases
|
|
2435
|
+
// automatically on visibility hidden; we re-acquire on
|
|
2436
|
+
// visibilitychange when back).
|
|
2437
|
+
// Wake Lock requires a user gesture, so we only acquire it from the
|
|
2438
|
+
// tap handler. Chrome/Edge auto-release on hidden; we hook
|
|
2439
|
+
// visibilitychange to re-request when the user comes back.
|
|
2440
|
+
let _wakeLock = null;
|
|
2441
|
+
async function _acquireWakeLock() {
|
|
2442
|
+
try {
|
|
2443
|
+
if (!("wakeLock" in navigator)) return;
|
|
2444
|
+
if (_wakeLock && !_wakeLock.released) return;
|
|
2445
|
+
_wakeLock = await navigator.wakeLock.request("screen");
|
|
2446
|
+
_wakeLock.addEventListener && _wakeLock.addEventListener("release", () => {
|
|
2447
|
+
console.info("[wake] lock released");
|
|
2448
|
+
});
|
|
2449
|
+
console.info("[wake] lock acquired");
|
|
2450
|
+
} catch (e) {
|
|
2451
|
+
// Common: NotAllowedError if called outside a user gesture, or
|
|
2452
|
+
// the tab is hidden. Not fatal.
|
|
2453
|
+
console.info("[wake] request failed:", e && e.name);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
async function _releaseWakeLock() {
|
|
2457
|
+
try { if (_wakeLock) await _wakeLock.release(); } catch { /* silent */ }
|
|
2458
|
+
_wakeLock = null;
|
|
2459
|
+
}
|
|
2460
|
+
// Rev448 (Carlos): fullscreen + keep-awake "always on" by default.
|
|
2461
|
+
// Browsers require a user gesture to request either, so we hook the
|
|
2462
|
+
// FIRST pointer tap and promote the visor to fullscreen + take the
|
|
2463
|
+
// Screen Wake Lock. The sailor can opt out via the ⛶ button — that
|
|
2464
|
+
// writes "false" to localStorage so later reloads skip the auto-on.
|
|
2465
|
+
const FS_PREF_KEY = "pypilot-newui.fullscreenAuto";
|
|
2466
|
+
const _fsAutoEnabled = () => {
|
|
2467
|
+
try { return localStorage.getItem(FS_PREF_KEY) !== "false"; } catch { return true; }
|
|
2468
|
+
};
|
|
2469
|
+
const _fsSetAuto = (on) => {
|
|
2470
|
+
try { localStorage.setItem(FS_PREF_KEY, on ? "true" : "false"); } catch {}
|
|
2471
|
+
};
|
|
2472
|
+
function _wireFullscreenAndWakeLock() {
|
|
2473
|
+
const btn = document.getElementById("fs-toggle");
|
|
2474
|
+
if (!btn) return;
|
|
2475
|
+
const updateIcon = () => {
|
|
2476
|
+
const isFs = !!document.fullscreenElement;
|
|
2477
|
+
btn.textContent = isFs ? "⤢" : "⛶";
|
|
2478
|
+
btn.classList.toggle("is-fullscreen", isFs);
|
|
2479
|
+
};
|
|
2480
|
+
const enterFs = async () => {
|
|
2481
|
+
const root = document.documentElement;
|
|
2482
|
+
if (document.fullscreenElement) return;
|
|
2483
|
+
if (root.requestFullscreen) await root.requestFullscreen();
|
|
2484
|
+
else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();
|
|
2485
|
+
};
|
|
2486
|
+
btn.addEventListener("click", async (ev) => {
|
|
2487
|
+
ev.stopPropagation();
|
|
2488
|
+
try {
|
|
2489
|
+
if (document.fullscreenElement) {
|
|
2490
|
+
_fsSetAuto(false); // sailor opted out; do not re-arm next load
|
|
2491
|
+
await document.exitFullscreen();
|
|
2492
|
+
await _releaseWakeLock();
|
|
2493
|
+
} else {
|
|
2494
|
+
_fsSetAuto(true);
|
|
2495
|
+
await enterFs();
|
|
2496
|
+
await _acquireWakeLock();
|
|
2497
|
+
}
|
|
2498
|
+
} catch (e) { console.warn("[fs] toggle failed:", e); }
|
|
2499
|
+
updateIcon();
|
|
2500
|
+
});
|
|
2501
|
+
document.addEventListener("fullscreenchange", updateIcon);
|
|
2502
|
+
document.addEventListener("visibilitychange", () => {
|
|
2503
|
+
if (document.visibilityState === "visible" && _wakeLock === null && _fsAutoEnabled()) {
|
|
2504
|
+
_acquireWakeLock().catch(() => {});
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
// Auto-arm on the FIRST user interaction. Fullscreen + Wake Lock
|
|
2508
|
+
// need a user gesture, so we piggy-back on whatever the sailor taps
|
|
2509
|
+
// first (usually a tab or the rose).
|
|
2510
|
+
if (_fsAutoEnabled()) {
|
|
2511
|
+
const armOnce = async () => {
|
|
2512
|
+
document.removeEventListener("pointerdown", armOnce, true);
|
|
2513
|
+
document.removeEventListener("keydown", armOnce, true);
|
|
2514
|
+
try { await enterFs(); } catch { /* silent */ }
|
|
2515
|
+
try { await _acquireWakeLock(); } catch { /* silent */ }
|
|
2516
|
+
updateIcon();
|
|
2517
|
+
};
|
|
2518
|
+
document.addEventListener("pointerdown", armOnce, true);
|
|
2519
|
+
document.addEventListener("keydown", armOnce, true);
|
|
2520
|
+
}
|
|
2521
|
+
updateIcon();
|
|
2522
|
+
}
|
|
2424
2523
|
function connectSK() {
|
|
2425
2524
|
const loc = window.location;
|
|
2426
2525
|
const wsProto = loc.protocol === "https:" ? "wss" : "ws";
|
|
@@ -3020,7 +3119,7 @@
|
|
|
3020
3119
|
if (state.windSpeedTrue == null) state.windSpeedTrue = numericOrNull(value);
|
|
3021
3120
|
break;
|
|
3022
3121
|
case "navigation.speedOverGround":
|
|
3023
|
-
state.sog = numericOrNull(value); renderCogAndCurrent(); break;
|
|
3122
|
+
state.sog = numericOrNull(value); renderCogAndCurrent(); renderRoseBottomChip(); break;
|
|
3024
3123
|
case "navigation.courseOverGroundTrue":
|
|
3025
3124
|
state.cog = numericOrNull(value); renderCogAndCurrent(); break;
|
|
3026
3125
|
case "navigation.speedThroughWater":
|
|
@@ -3040,14 +3139,14 @@
|
|
|
3040
3139
|
// First non-null wins: prefer belowKeel > belowTransducer > belowSurface.
|
|
3041
3140
|
{
|
|
3042
3141
|
const v = numericOrNull(value);
|
|
3043
|
-
if (v != null) state.depth = v;
|
|
3142
|
+
if (v != null) { state.depth = v; renderRoseBottomChip(); }
|
|
3044
3143
|
}
|
|
3045
3144
|
break;
|
|
3046
3145
|
// Rev58: polar-performance-plugin. All values kept in SI (m/s for
|
|
3047
3146
|
// speeds, rad for angles) so the DASH_DATA formatters can convert
|
|
3048
3147
|
// consistently. Water temperature stays in kelvin.
|
|
3049
3148
|
case "performance.polarSpeed": state.polarSpeed = numericOrNull(value); break;
|
|
3050
|
-
case "performance.polarSpeedRatio": state.polarRatio = numericOrNull(value); break;
|
|
3149
|
+
case "performance.polarSpeedRatio": state.polarRatio = numericOrNull(value); renderRoseBottomChip(); break;
|
|
3051
3150
|
case "performance.velocityMadeGood": state.vmg = numericOrNull(value); break;
|
|
3052
3151
|
case "performance.targetAngle": state.targetAngle = numericOrNull(value); break;
|
|
3053
3152
|
case "performance.targetSpeed": state.targetSpeed = numericOrNull(value); break;
|
|
@@ -3108,6 +3207,54 @@
|
|
|
3108
3207
|
_dashRenderCorners();
|
|
3109
3208
|
renderWindRose();
|
|
3110
3209
|
renderTargetArrow();
|
|
3210
|
+
renderHeadingPrecision();
|
|
3211
|
+
}
|
|
3212
|
+
// Rev443 (Carlos QA Rev442): the second line of #rose-hdg-chip shows
|
|
3213
|
+
// how FAR OFF the autopilot is from its own target — the sailor's
|
|
3214
|
+
// "precision gauge". Not the target value itself.
|
|
3215
|
+
// compass mode → signed shortest-arc (target − heading)
|
|
3216
|
+
// wind mode → signed (target − AWA), target is the AWA setpoint
|
|
3217
|
+
// true-wind → signed (target − TWA)
|
|
3218
|
+
// aproado/empopado → wind flavour same as above
|
|
3219
|
+
// Rev444 (Carlos QA Rev443): sign flipped — the sailor reads the
|
|
3220
|
+
// number as "how much and in which direction the pilot still has to
|
|
3221
|
+
// correct": negative = pilot must go to port (babor), positive = pilot
|
|
3222
|
+
// must go to starboard (estribor). Also, when the AP is NOT engaged
|
|
3223
|
+
// (state.engaged === false) the value is "--": state.target can carry
|
|
3224
|
+
// a stale number from the previous AP session.
|
|
3225
|
+
// Colour: |err|<3° green, |err|<10° orange, else red.
|
|
3226
|
+
function renderHeadingPrecision() {
|
|
3227
|
+
const el = document.getElementById("rose-hdg-prec");
|
|
3228
|
+
if (!el) return;
|
|
3229
|
+
const mode = String(state.mode || "").toLowerCase();
|
|
3230
|
+
let currentRad = null, targetRad = null;
|
|
3231
|
+
if (mode === "wind" || mode === "aproado") {
|
|
3232
|
+
currentRad = (typeof state.windAngle === "number") ? state.windAngle : null;
|
|
3233
|
+
targetRad = (typeof state.target === "number") ? state.target : null;
|
|
3234
|
+
} else if (mode === "true wind" || mode === "empopado") {
|
|
3235
|
+
currentRad = (typeof state.windAngleTrue === "number") ? state.windAngleTrue : null;
|
|
3236
|
+
targetRad = (typeof state.target === "number") ? state.target : null;
|
|
3237
|
+
} else {
|
|
3238
|
+
currentRad = (typeof state.heading === "number") ? state.heading : null;
|
|
3239
|
+
targetRad = (typeof state.target === "number") ? state.target : null;
|
|
3240
|
+
}
|
|
3241
|
+
if (!state.engaged || currentRad == null || targetRad == null) {
|
|
3242
|
+
el.textContent = "--";
|
|
3243
|
+
el.setAttribute("fill", "#8a98a8");
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
// Sign convention Carlos: how much the pilot still has to correct.
|
|
3247
|
+
// target - current → +ve = pilot must go starboard; -ve = pilot
|
|
3248
|
+
// must go port.
|
|
3249
|
+
let errRad = targetRad - currentRad;
|
|
3250
|
+
while (errRad > Math.PI) errRad -= 2 * Math.PI;
|
|
3251
|
+
while (errRad < -Math.PI) errRad += 2 * Math.PI;
|
|
3252
|
+
const errDeg = errRad * RAD2DEG;
|
|
3253
|
+
const abs = Math.abs(errDeg);
|
|
3254
|
+
const col = abs < 3 ? "#2ecc71" : (abs < 10 ? "#f5a623" : "#ff3b30");
|
|
3255
|
+
const sign = errDeg > 0 ? "+" : (errDeg < 0 ? "-" : "");
|
|
3256
|
+
el.textContent = `${sign}${Math.abs(Math.round(errDeg))}°`;
|
|
3257
|
+
el.setAttribute("fill", col);
|
|
3111
3258
|
}
|
|
3112
3259
|
|
|
3113
3260
|
// Rev48: 4 configurable corner tiles. Each corner shows one field
|
|
@@ -3237,6 +3384,157 @@
|
|
|
3237
3384
|
_dashRenderCorners();
|
|
3238
3385
|
renderWindRose();
|
|
3239
3386
|
}
|
|
3387
|
+
// Rev439 → Rev445 (Carlos): the chip at the bottom of the rose is a
|
|
3388
|
+
// user-configurable carousel. Short-tap cycles through the selected
|
|
3389
|
+
// paths; long-press opens a modal where the sailor picks up to 4
|
|
3390
|
+
// paths from DASH_DATA (reuses the same catalog as the 4 configurable
|
|
3391
|
+
// corners). Persisted in localStorage.
|
|
3392
|
+
const ROSE_BOTTOM_CHIP_LS_KEY = "pypilot-newui.roseBottomChipPaths";
|
|
3393
|
+
const ROSE_BOTTOM_CHIP_MAX = 4;
|
|
3394
|
+
const ROSE_BOTTOM_CHIP_DEFAULT = ["sog", "depth", "perfPolarPct"];
|
|
3395
|
+
state.roseBottomChipPaths = (() => {
|
|
3396
|
+
try {
|
|
3397
|
+
const raw = localStorage.getItem(ROSE_BOTTOM_CHIP_LS_KEY);
|
|
3398
|
+
if (raw) {
|
|
3399
|
+
const arr = JSON.parse(raw);
|
|
3400
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
3401
|
+
const filt = arr.filter((k) => typeof k === "string").slice(0, ROSE_BOTTOM_CHIP_MAX);
|
|
3402
|
+
if (filt.length > 0) return filt;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
} catch {}
|
|
3406
|
+
return ROSE_BOTTOM_CHIP_DEFAULT.slice();
|
|
3407
|
+
})();
|
|
3408
|
+
state.roseBottomChipIdx = 0;
|
|
3409
|
+
function _roseChipKeyLabel(entry) {
|
|
3410
|
+
if (!entry) return "";
|
|
3411
|
+
return typeof entry.label === "function" ? (entry.label() || "") : (entry.label || "");
|
|
3412
|
+
}
|
|
3413
|
+
function _roseChipValueOf(entry) {
|
|
3414
|
+
if (!entry) return "---";
|
|
3415
|
+
const v = entry.read();
|
|
3416
|
+
if (v && typeof v === "object" && "value" in v) {
|
|
3417
|
+
return v.value + (v.sub ? " " + v.sub : "");
|
|
3418
|
+
}
|
|
3419
|
+
return String(v);
|
|
3420
|
+
}
|
|
3421
|
+
function renderRoseBottomChip() {
|
|
3422
|
+
const label = document.getElementById("rose-speed-label");
|
|
3423
|
+
const text = document.getElementById("rose-speed");
|
|
3424
|
+
const chip = document.getElementById("rose-speed-chip");
|
|
3425
|
+
if (!chip && !label && !text) return;
|
|
3426
|
+
const paths = state.roseBottomChipPaths && state.roseBottomChipPaths.length
|
|
3427
|
+
? state.roseBottomChipPaths : ROSE_BOTTOM_CHIP_DEFAULT;
|
|
3428
|
+
if (state.roseBottomChipIdx >= paths.length) state.roseBottomChipIdx = 0;
|
|
3429
|
+
const key = paths[state.roseBottomChipIdx];
|
|
3430
|
+
const entry = DASH_DATA[key];
|
|
3431
|
+
if (!entry) {
|
|
3432
|
+
if (label) label.textContent = "?";
|
|
3433
|
+
if (text) text.textContent = "---";
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
if (label) label.textContent = _roseChipKeyLabel(entry);
|
|
3437
|
+
if (text) text.textContent = _roseChipValueOf(entry);
|
|
3438
|
+
if (chip) chip.classList.remove("wind-app", "wind-true");
|
|
3439
|
+
}
|
|
3440
|
+
function _cycleRoseBottomChip() {
|
|
3441
|
+
const paths = state.roseBottomChipPaths && state.roseBottomChipPaths.length
|
|
3442
|
+
? state.roseBottomChipPaths : ROSE_BOTTOM_CHIP_DEFAULT;
|
|
3443
|
+
state.roseBottomChipIdx = (state.roseBottomChipIdx + 1) % paths.length;
|
|
3444
|
+
renderRoseBottomChip();
|
|
3445
|
+
}
|
|
3446
|
+
function _saveRoseBottomChipPaths() {
|
|
3447
|
+
try { localStorage.setItem(ROSE_BOTTOM_CHIP_LS_KEY, JSON.stringify(state.roseBottomChipPaths)); } catch {}
|
|
3448
|
+
}
|
|
3449
|
+
// Rev445: modal selector for the bottom chip. Lists every DASH_DATA
|
|
3450
|
+
// key (base + extras + custom) with a checkbox. Max 4 selected: when
|
|
3451
|
+
// the sailor tries to add a 5th, the oldest in the ordered selection
|
|
3452
|
+
// is dropped. "Guardar" persists + repaints; "Cancelar" discards.
|
|
3453
|
+
function _openRoseChipSelectorModal() {
|
|
3454
|
+
let m = document.getElementById("rose-chip-selector-modal");
|
|
3455
|
+
if (!m) {
|
|
3456
|
+
m = document.createElement("div");
|
|
3457
|
+
m.id = "rose-chip-selector-modal";
|
|
3458
|
+
m.className = "tune-fork-backdrop";
|
|
3459
|
+
m.innerHTML =
|
|
3460
|
+
`<div class="modal-card" style="max-width:420px">` +
|
|
3461
|
+
` <h3 id="rose-chip-sel-title" style="margin:0 0 6px 0"></h3>` +
|
|
3462
|
+
` <div id="rose-chip-sel-sub" style="color:var(--fg-dim);font-size:12px;margin-bottom:8px"></div>` +
|
|
3463
|
+
` <ul id="rose-chip-sel-list" style="list-style:none;padding:0;margin:0 0 10px 0;max-height:50vh;overflow-y:auto"></ul>` +
|
|
3464
|
+
` <div class="tune-fork-actions">` +
|
|
3465
|
+
` <button type="button" id="rose-chip-sel-save" class="primary"></button>` +
|
|
3466
|
+
` <button type="button" id="rose-chip-sel-cancel" class="ghost"></button>` +
|
|
3467
|
+
` </div>` +
|
|
3468
|
+
`</div>`;
|
|
3469
|
+
document.body.appendChild(m);
|
|
3470
|
+
m.addEventListener("click", (ev) => { if (ev.target === m) m.hidden = true; });
|
|
3471
|
+
m.querySelector("#rose-chip-sel-cancel").addEventListener("click", () => { m.hidden = true; });
|
|
3472
|
+
m.querySelector("#rose-chip-sel-save").addEventListener("click", () => {
|
|
3473
|
+
const ul = m.querySelector("#rose-chip-sel-list");
|
|
3474
|
+
const picked = Array.from(ul.querySelectorAll("input[type=checkbox]:checked"))
|
|
3475
|
+
.map((cb) => cb.dataset.key);
|
|
3476
|
+
if (picked.length === 0) { m.hidden = true; return; }
|
|
3477
|
+
state.roseBottomChipPaths = picked.slice(0, ROSE_BOTTOM_CHIP_MAX);
|
|
3478
|
+
state.roseBottomChipIdx = 0;
|
|
3479
|
+
_saveRoseBottomChipPaths();
|
|
3480
|
+
renderRoseBottomChip();
|
|
3481
|
+
m.hidden = true;
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
const es = (typeof currentLang === "function" && currentLang() === "es");
|
|
3485
|
+
m.querySelector("#rose-chip-sel-title").textContent = es ? "Chip inferior de la rosa" : "Rose bottom chip";
|
|
3486
|
+
m.querySelector("#rose-chip-sel-sub").textContent = es
|
|
3487
|
+
? `Elige hasta ${ROSE_BOTTOM_CHIP_MAX} datos; se cicla con un toque corto.`
|
|
3488
|
+
: `Pick up to ${ROSE_BOTTOM_CHIP_MAX} datapoints; short-tap cycles them.`;
|
|
3489
|
+
m.querySelector("#rose-chip-sel-save").textContent = es ? "Guardar" : "Save";
|
|
3490
|
+
m.querySelector("#rose-chip-sel-cancel").textContent = es ? "Cancelar" : "Cancel";
|
|
3491
|
+
const ul = m.querySelector("#rose-chip-sel-list");
|
|
3492
|
+
ul.textContent = "";
|
|
3493
|
+
// Build the catalog: base keys first, then opt-in extras the user
|
|
3494
|
+
// enabled for the corners (same DASH_EXTRA set), then custom paths.
|
|
3495
|
+
const baseKeys = ["sog","cog","hdg","depth","awa","aws","twa","tws","twd","wind","tgt","mode"];
|
|
3496
|
+
const extraKeys = DASH_EXTRA_KEYS.filter((k) => _dashExtras.has(k));
|
|
3497
|
+
const custKeys = _dashCustom.map((_, i) => _dashCustomKey(i));
|
|
3498
|
+
const catalog = [...baseKeys, ...extraKeys, ...custKeys].filter((k) => DASH_DATA[k]);
|
|
3499
|
+
const selected = new Set(state.roseBottomChipPaths || []);
|
|
3500
|
+
const order = state.roseBottomChipPaths.slice(); // kept to drop oldest on overflow
|
|
3501
|
+
const refreshCountUI = () => {
|
|
3502
|
+
const n = ul.querySelectorAll("input:checked").length;
|
|
3503
|
+
m.querySelector("#rose-chip-sel-title").textContent =
|
|
3504
|
+
(es ? "Chip inferior de la rosa" : "Rose bottom chip") + ` (${n}/${ROSE_BOTTOM_CHIP_MAX})`;
|
|
3505
|
+
};
|
|
3506
|
+
for (const k of catalog) {
|
|
3507
|
+
const li = document.createElement("li");
|
|
3508
|
+
li.style.cssText = "display:flex;align-items:center;gap:10px;padding:6px 4px;border-bottom:1px solid var(--border)";
|
|
3509
|
+
const cb = document.createElement("input");
|
|
3510
|
+
cb.type = "checkbox"; cb.dataset.key = k;
|
|
3511
|
+
cb.checked = selected.has(k);
|
|
3512
|
+
cb.addEventListener("change", () => {
|
|
3513
|
+
if (cb.checked) {
|
|
3514
|
+
if (!order.includes(k)) order.push(k);
|
|
3515
|
+
// Enforce the max — drop the OLDEST pick (not the one just made).
|
|
3516
|
+
while (order.length > ROSE_BOTTOM_CHIP_MAX) {
|
|
3517
|
+
const drop = order.shift();
|
|
3518
|
+
const dropCb = ul.querySelector(`input[data-key="${drop}"]`);
|
|
3519
|
+
if (dropCb) dropCb.checked = false;
|
|
3520
|
+
}
|
|
3521
|
+
} else {
|
|
3522
|
+
const i = order.indexOf(k);
|
|
3523
|
+
if (i >= 0) order.splice(i, 1);
|
|
3524
|
+
}
|
|
3525
|
+
refreshCountUI();
|
|
3526
|
+
});
|
|
3527
|
+
const span = document.createElement("span");
|
|
3528
|
+
span.style.cssText = "flex:1;font-size:14px";
|
|
3529
|
+
span.textContent = _roseChipKeyLabel(DASH_DATA[k]) || k;
|
|
3530
|
+
li.appendChild(cb);
|
|
3531
|
+
li.appendChild(span);
|
|
3532
|
+
ul.appendChild(li);
|
|
3533
|
+
}
|
|
3534
|
+
refreshCountUI();
|
|
3535
|
+
m.hidden = false;
|
|
3536
|
+
m.classList.add("open");
|
|
3537
|
+
}
|
|
3240
3538
|
// Called from applyValue whenever the AP mode changes. If the user has
|
|
3241
3539
|
// not pinned the wind-corner preference, follow the mode: true-wind
|
|
3242
3540
|
// modes show TWA/TWS, everything else shows AWA/AWS.
|
|
@@ -3649,47 +3947,17 @@
|
|
|
3649
3947
|
// seconds, parsed name) + a short "alarma/aviso" prefix tied to the
|
|
3650
3948
|
// notification level. i18n keys cover ES/EN.
|
|
3651
3949
|
function _aisVoiceMessage(value, mmsi, newState) {
|
|
3652
|
-
|
|
3653
|
-
//
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
} catch { /* silent */ }
|
|
3659
|
-
if (!name || name === mmsi || /^\d{6,}$/.test(name)) {
|
|
3660
|
-
name = t("ais.objetivo") || "objetivo";
|
|
3661
|
-
}
|
|
3662
|
-
// Distance: metres for < 500 m, miles for the rest (sailors read
|
|
3663
|
-
// CPA in NM but close ranges are intuitive in metres).
|
|
3664
|
-
let distStr = "";
|
|
3665
|
-
if (typeof d.cpa === "number" && isFinite(d.cpa)) {
|
|
3666
|
-
if (d.cpa < 500) {
|
|
3667
|
-
distStr = `${Math.round(d.cpa)} ${t("ais.voice.metres") || "metros"}`;
|
|
3668
|
-
} else {
|
|
3669
|
-
const nm = d.cpa / 1852;
|
|
3670
|
-
distStr = `${nm.toFixed(nm < 1 ? 2 : 1)} ${t("ais.voice.miles") || "millas"}`;
|
|
3671
|
-
}
|
|
3672
|
-
}
|
|
3673
|
-
// TCPA: positive seconds → minutes rounded; negative or 0 means
|
|
3674
|
-
// the pass is already happening ("ya").
|
|
3675
|
-
let timeStr = "";
|
|
3676
|
-
if (typeof d.tcpa === "number" && isFinite(d.tcpa)) {
|
|
3677
|
-
if (d.tcpa <= 10) {
|
|
3678
|
-
timeStr = t("ais.voice.now") || "ya";
|
|
3679
|
-
} else {
|
|
3680
|
-
const min = Math.max(1, Math.round(d.tcpa / 60));
|
|
3681
|
-
timeStr = `${t("ais.voice.in") || "en"} ${min} ${(min === 1 ? (t("ais.voice.minute") || "minuto") : (t("ais.voice.minutes") || "minutos"))}`;
|
|
3682
|
-
}
|
|
3683
|
-
}
|
|
3950
|
+
// Rev441 (Carlos 2026-10-10): "la alarma sigue diciendo el número
|
|
3951
|
+
// MMSI cuando debería decir Alarma Target en rumbo de colisión
|
|
3952
|
+
// (sin decir todos los números que no aportan nada)". Simplified
|
|
3953
|
+
// to a single short sentence per level — the sailor glances at
|
|
3954
|
+
// the infobox for CPA/TCPA; the voice is now a safety nudge, not
|
|
3955
|
+
// a report.
|
|
3684
3956
|
const kind = (newState === "alarm")
|
|
3685
3957
|
? (t("ais.voice.alarm") || "Alarma A I S")
|
|
3686
3958
|
: (t("ais.voice.warn") || "Aviso A I S");
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
const parts = [kind + ",", name];
|
|
3690
|
-
if (distStr) parts.push(t("ais.voice.at") || "a", distStr);
|
|
3691
|
-
if (timeStr) parts.push(timeStr);
|
|
3692
|
-
return parts.join(" ");
|
|
3959
|
+
const line = t("ais.voice.collisionCourse") || "target en rumbo de colisión";
|
|
3960
|
+
return `${kind}, ${line}`;
|
|
3693
3961
|
}
|
|
3694
3962
|
function _handleAisAlarmDelta(path, value) {
|
|
3695
3963
|
// Rev434: respect the "AIS collision alarm" overlay toggle. Still
|
|
@@ -3712,7 +3980,12 @@
|
|
|
3712
3980
|
// the alarm is treated as "silent info".
|
|
3713
3981
|
const cached = state.aisTargetPos.get(mmsi);
|
|
3714
3982
|
const targetStationary = cached && typeof cached.sog === "number" && cached.sog < AIS_IGNORE_SOG_MS;
|
|
3715
|
-
|
|
3983
|
+
// Rev441 (Carlos 2026-10-10): "cuando hagamos ack, que siga la
|
|
3984
|
+
// flecha". The map now carries ackeadas too — only the delta that
|
|
3985
|
+
// declares the threat gone (!isActive) or a stationary target
|
|
3986
|
+
// drops the entry. The ACK state is a per-render visual, not a
|
|
3987
|
+
// reason to remove the target from the rose.
|
|
3988
|
+
if (!value || !isActive || targetStationary) {
|
|
3716
3989
|
state.aisAlarms.delete(mmsi);
|
|
3717
3990
|
} else {
|
|
3718
3991
|
state.aisAlarms.set(mmsi, { value, mmsi });
|
|
@@ -3777,10 +4050,15 @@
|
|
|
3777
4050
|
const tb = Math.abs(b.value.data?.tcpa ?? 1e9);
|
|
3778
4051
|
return ta - tb;
|
|
3779
4052
|
});
|
|
4053
|
+
// Rev441 (Carlos 2026-10-10): "cuando sean varias que queden solo las
|
|
4054
|
+
// 3 más cercanas en TCPA". Cap the painted set to the 3 most urgent.
|
|
4055
|
+
// The infobox nav still walks ALL alarms so the sailor can scroll
|
|
4056
|
+
// through hidden ones.
|
|
4057
|
+
const topN = alarms.slice(0, 3);
|
|
3780
4058
|
// Draw one small red boat per active threat. r = 92 lands on the
|
|
3781
4059
|
// outer edge of the compass circle (SVG viewBox -100 -110 200 220).
|
|
3782
4060
|
const R = 92;
|
|
3783
|
-
for (const a of
|
|
4061
|
+
for (const a of topN) {
|
|
3784
4062
|
const d = a.value.data || {};
|
|
3785
4063
|
// Rev436 (Carlos QA Rev435): use NOW positions, not cpaPositions
|
|
3786
4064
|
// (which are the projected positions at the CPA future instant
|
|
@@ -3789,9 +4067,20 @@
|
|
|
3789
4067
|
// + the target's position cached from the SK REST API. Fallback
|
|
3790
4068
|
// to cpaPositions if the cache has not landed yet.
|
|
3791
4069
|
const cached = state.aisTargetPos.get(a.mmsi);
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
4070
|
+
// Rev448 (Carlos QA Rev447): state.position carries {lat, lon}
|
|
4071
|
+
// (short keys), while _aisBearingDeg reads .latitude / .longitude.
|
|
4072
|
+
// The old check `state.position.latitude === "number"` ALWAYS
|
|
4073
|
+
// failed and silently fell back to cpaPositions.self — the
|
|
4074
|
+
// PROJECTED position of our vessel at CPA time. Bearing was then
|
|
4075
|
+
// computed between two FUTURE positions, which can land 180° off
|
|
4076
|
+
// the current geometry. Adapt once here into the SK long-key
|
|
4077
|
+
// shape expected by the geodesic helper.
|
|
4078
|
+
let selfPos = null;
|
|
4079
|
+
if (state.position && typeof state.position.lat === "number") {
|
|
4080
|
+
selfPos = { latitude: state.position.lat, longitude: state.position.lon };
|
|
4081
|
+
} else if (d.cpaPositions && d.cpaPositions.self) {
|
|
4082
|
+
selfPos = d.cpaPositions.self;
|
|
4083
|
+
}
|
|
3795
4084
|
const tgtPos = (cached && cached.pos) || d.cpaPositions?.target;
|
|
3796
4085
|
const relDeg = _aisRelativeBearingDeg(selfPos, tgtPos, state.heading);
|
|
3797
4086
|
if (relDeg == null) continue;
|
|
@@ -3802,14 +4091,45 @@
|
|
|
3802
4091
|
// points to (0,0). The triangle is drawn pointing up (0,-h), so
|
|
3803
4092
|
// we rotate by relDeg + 180 to flip.
|
|
3804
4093
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
3805
|
-
|
|
3806
|
-
g.setAttribute("
|
|
4094
|
+
const acked = _aisIsMmsiAcked(a.mmsi);
|
|
4095
|
+
g.setAttribute("class", `ais-target ais-${a.value.state}${acked ? " ais-acked" : ""}`);
|
|
3807
4096
|
g.dataset.mmsi = a.mmsi;
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
4097
|
+
// Rev443 (Carlos QA Rev442): the AIS target is now the full
|
|
4098
|
+
// boat-cenital.svg top-view silhouette (same asset as the own
|
|
4099
|
+
// vessel in the rose centre), tinted per state via an SVG
|
|
4100
|
+
// feColorMatrix filter defined in #wind-rose <defs>:
|
|
4101
|
+
// alarm → red (150% scale, blink)
|
|
4102
|
+
// warn → orange (100% scale, fixed)
|
|
4103
|
+
// acked → dim blue-grey (100% scale, no blink)
|
|
4104
|
+
// The whole <g> carries translate+rotate+scale so the sprite is
|
|
4105
|
+
// centred on the target position and the hull points toward
|
|
4106
|
+
// self (bow forward of the relative bearing).
|
|
4107
|
+
const scale = acked ? 1.0 : (a.value.state === "alarm" ? 1.5 : 1.0);
|
|
4108
|
+
// Rev447 (Carlos QA Rev445): the triangle used in Rev441-442 had
|
|
4109
|
+
// its tip at (0,-10) — "forward = -y local" — so `rotate(relDeg+180)`
|
|
4110
|
+
// pointed the tip at self. The boat-cenital.svg hull has its BOW
|
|
4111
|
+
// at +x local (the asset is drawn horizontal, bow to the right of
|
|
4112
|
+
// the viewBox). The correct SVG rotation to point +x local at the
|
|
4113
|
+
// origin from relative bearing B is `rotate(B + 90)`:
|
|
4114
|
+
// B=0 (target to N): rotate 90° → bow faces +y global → origin
|
|
4115
|
+
// B=90 (target to E): rotate 180° → bow faces -x global → origin
|
|
4116
|
+
// B=180 (target to S): rotate 270° → bow faces -y global → origin
|
|
4117
|
+
g.setAttribute("transform",
|
|
4118
|
+
`translate(${x.toFixed(1)},${y.toFixed(1)}) rotate(${(relDeg + 90).toFixed(1)}) scale(${scale})`);
|
|
4119
|
+
const filterId = acked ? "ais-tint-acked"
|
|
4120
|
+
: (a.value.state === "alarm" ? "ais-tint-alarm" : "ais-tint-warn");
|
|
4121
|
+
// Base image: 28 × 9.3 units (viewBox 456×152 → aspect ≈ 3:1),
|
|
4122
|
+
// centred on (0,0). Scale applied via the parent <g>.
|
|
4123
|
+
const img = document.createElementNS("http://www.w3.org/2000/svg", "image");
|
|
4124
|
+
img.setAttributeNS("http://www.w3.org/1999/xlink", "href", "boat-cenital.svg");
|
|
4125
|
+
img.setAttribute("href", "boat-cenital.svg");
|
|
4126
|
+
img.setAttribute("x", "-14");
|
|
4127
|
+
img.setAttribute("y", "-4.65");
|
|
4128
|
+
img.setAttribute("width", "28");
|
|
4129
|
+
img.setAttribute("height", "9.3");
|
|
4130
|
+
img.setAttribute("filter", `url(#${filterId})`);
|
|
4131
|
+
img.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
4132
|
+
g.appendChild(img);
|
|
3813
4133
|
layer.appendChild(g);
|
|
3814
4134
|
}
|
|
3815
4135
|
// Rev432: infobox + ACK as a persistent HTML div.
|
|
@@ -3819,6 +4139,16 @@
|
|
|
3819
4139
|
if (!box) return;
|
|
3820
4140
|
if (typeof state._aisInfoboxIdx !== "number") state._aisInfoboxIdx = 0;
|
|
3821
4141
|
if (state._aisInfoboxIdx >= alarms.length) state._aisInfoboxIdx = 0;
|
|
4142
|
+
// Rev441: if the current idx points at an ACKed alarm, prefer the
|
|
4143
|
+
// first NON-ACKed one so a brand-new threat auto-shows its data
|
|
4144
|
+
// instead of being hidden behind a silenced row. If all are
|
|
4145
|
+
// ACKed, do not auto-show (sailor can tap a triangle to open).
|
|
4146
|
+
const activeIdxs = alarms.map((a, i) => _aisIsMmsiAcked(a.mmsi) ? -1 : i).filter((i) => i >= 0);
|
|
4147
|
+
if (!state._aisInfoboxUserPicked) {
|
|
4148
|
+
if (activeIdxs.length > 0 && _aisIsMmsiAcked(alarms[state._aisInfoboxIdx].mmsi)) {
|
|
4149
|
+
state._aisInfoboxIdx = activeIdxs[0];
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
3822
4152
|
const top = alarms[state._aisInfoboxIdx];
|
|
3823
4153
|
const d = top.value.data || {};
|
|
3824
4154
|
const cpaNm = (d.cpa != null) ? (d.cpa / 1852) : null;
|
|
@@ -3874,7 +4204,33 @@
|
|
|
3874
4204
|
closingEl.hidden = true;
|
|
3875
4205
|
}
|
|
3876
4206
|
}
|
|
3877
|
-
|
|
4207
|
+
// Rev441: hide the infobox if the entry we would show is ACKed and
|
|
4208
|
+
// no user tap forced it open; otherwise paint and show it.
|
|
4209
|
+
const topAcked = _aisIsMmsiAcked(top.mmsi);
|
|
4210
|
+
// Rev442 (Carlos QA Rev441): the ACK button labels + disables when
|
|
4211
|
+
// this MMSI is already silenced. The sailor still sees the data
|
|
4212
|
+
// without being able to re-ACK (nothing to do; the entry is
|
|
4213
|
+
// already in state.aisLocalAckedMmsis for 10 min).
|
|
4214
|
+
const ackBtn = document.getElementById("ais-infobox-ack");
|
|
4215
|
+
if (ackBtn) {
|
|
4216
|
+
if (topAcked) {
|
|
4217
|
+
ackBtn.textContent = t("ais.acked") || "ACKED";
|
|
4218
|
+
ackBtn.setAttribute("disabled", "");
|
|
4219
|
+
ackBtn.classList.add("is-acked");
|
|
4220
|
+
} else {
|
|
4221
|
+
ackBtn.textContent = t("ais.ack") || "ACK";
|
|
4222
|
+
ackBtn.removeAttribute("disabled");
|
|
4223
|
+
ackBtn.classList.remove("is-acked");
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
// Rev442 (Carlos QA Rev441): if the user tapped a specific arrow,
|
|
4227
|
+
// show it even if ACKed (so the data popup can be inspected).
|
|
4228
|
+
// Otherwise hide ACKed targets so new threats own the box.
|
|
4229
|
+
if (topAcked && !state._aisInfoboxUserPicked) {
|
|
4230
|
+
box.hidden = true;
|
|
4231
|
+
} else {
|
|
4232
|
+
box.hidden = false;
|
|
4233
|
+
}
|
|
3878
4234
|
}
|
|
3879
4235
|
// Rev438: lightweight modal to list silenced MMSIs with un-silence
|
|
3880
4236
|
// per entry + "un-silence all". Uses _pmPrompt-style elements.
|
|
@@ -3950,8 +4306,12 @@
|
|
|
3950
4306
|
// what UUID the next delta carries. Even if the id is empty (edge
|
|
3951
4307
|
// case) we still silence the mmsi — the whole point of ACK is to
|
|
3952
4308
|
// stop the specific vessel being pestered.
|
|
4309
|
+
// Rev441 (Carlos 2026-10-10): keep the arrow on the rose after ACK
|
|
4310
|
+
// ("que siga la flecha"). We no longer delete from state.aisAlarms;
|
|
4311
|
+
// renderAisAlarms paints the triangle with class .ais-acked (dim,
|
|
4312
|
+
// no blink). The entry is dropped by handleDelta only when the
|
|
4313
|
+
// plugin declares the threat gone.
|
|
3953
4314
|
try { state.aisLocalAckedMmsis.set(mmsi, Date.now() + AIS_ACK_TTL_MS); } catch { /* silent */ }
|
|
3954
|
-
try { state.aisAlarms.delete(mmsi); } catch { /* silent */ }
|
|
3955
4315
|
try { renderAisAlarms(); } catch { /* silent */ }
|
|
3956
4316
|
if (!id) return;
|
|
3957
4317
|
try {
|
|
@@ -4034,38 +4394,11 @@
|
|
|
4034
4394
|
}
|
|
4035
4395
|
}
|
|
4036
4396
|
|
|
4037
|
-
//
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
//
|
|
4041
|
-
|
|
4042
|
-
const useTrue = state.windCornerShow === "twa";
|
|
4043
|
-
const primarySpd = useTrue ? state.windSpeedTrue : state.windSpeed;
|
|
4044
|
-
const primaryTag = useTrue ? "TWS" : "AWS";
|
|
4045
|
-
let windSpeed = primarySpd, tag = primaryTag;
|
|
4046
|
-
if (windSpeed == null) {
|
|
4047
|
-
// Fall back to the other source so the number is not "---" when only
|
|
4048
|
-
// one wind flavour is available (typical on boats without SOG).
|
|
4049
|
-
const altSpd = useTrue ? state.windSpeed : state.windSpeedTrue;
|
|
4050
|
-
const altTag = useTrue ? "AWS" : "TWS";
|
|
4051
|
-
if (altSpd != null) { windSpeed = altSpd; tag = altTag; }
|
|
4052
|
-
}
|
|
4053
|
-
const speedLabel = document.getElementById("rose-speed-label");
|
|
4054
|
-
const speedText = document.getElementById("rose-speed");
|
|
4055
|
-
const chip = document.getElementById("rose-speed-chip");
|
|
4056
|
-
if (speedLabel) speedLabel.textContent = windSpeed == null ? "" : tag;
|
|
4057
|
-
if (speedText) {
|
|
4058
|
-
speedText.textContent = windSpeed == null
|
|
4059
|
-
? "--- kn"
|
|
4060
|
-
: (windSpeed * 1.94384).toFixed(1) + " kn";
|
|
4061
|
-
}
|
|
4062
|
-
// Rev152 (Carlos): tint the WHOLE chip (label + value) with the
|
|
4063
|
-
// matching arrow colour - AWS gets amber, TWS gets teal. Toggling
|
|
4064
|
-
// a class on the chip lets CSS colour both children at once.
|
|
4065
|
-
if (chip) {
|
|
4066
|
-
chip.classList.remove("wind-app", "wind-true");
|
|
4067
|
-
if (windSpeed != null) chip.classList.add(tag === "TWS" ? "wind-true" : "wind-app");
|
|
4068
|
-
}
|
|
4397
|
+
// Rev439 (Carlos): the bottom chip on the rose no longer mirrors
|
|
4398
|
+
// AWS/TWS. It cycles SOG → Depth → %Polar on tap (persisted in
|
|
4399
|
+
// localStorage). The 4 corner tiles keep their own "wind" option
|
|
4400
|
+
// and its TWA/AWS toggle intact.
|
|
4401
|
+
renderRoseBottomChip();
|
|
4069
4402
|
}
|
|
4070
4403
|
|
|
4071
4404
|
// Rev57: target indicator - a small cyan diamond OUTSIDE the compass
|
|
@@ -7131,7 +7464,11 @@
|
|
|
7131
7464
|
li.setAttribute("role", "option");
|
|
7132
7465
|
li.dataset.mode = String(v);
|
|
7133
7466
|
if (v === state.mode) li.classList.add("active");
|
|
7134
|
-
|
|
7467
|
+
// Rev441 (Carlos 2026-10-10): the modal shows the LONG labels
|
|
7468
|
+
// ("Viento Aparente", "Viento Real") so wind modes are explicit
|
|
7469
|
+
// on first open. The button on the bbar still uses the SHORT
|
|
7470
|
+
// label ("Aparente" / "Real") to fit the narrow chip.
|
|
7471
|
+
li.textContent = _modeLabel(v, true);
|
|
7135
7472
|
list.appendChild(li);
|
|
7136
7473
|
}
|
|
7137
7474
|
// Refresh the profile sub-select so its header shows the active one.
|
|
@@ -7514,21 +7851,28 @@
|
|
|
7514
7851
|
if (!res.ok) throw new Error(`${op}: HTTP ${res.status}`);
|
|
7515
7852
|
return res;
|
|
7516
7853
|
}
|
|
7854
|
+
// Rev446 (Carlos "sigue pidiendo de vez en cuando token en sk server"):
|
|
7855
|
+
// require THREE consecutive 401 writes before wiping the token and
|
|
7856
|
+
// kicking off a fresh Access Request. A single 401 (SK restart in
|
|
7857
|
+
// flight, transient proxy hiccup) used to be enough to invalidate a
|
|
7858
|
+
// perfectly good permanent token and pop the admin-approval modal on
|
|
7859
|
+
// the sailor's lap.
|
|
7860
|
+
let _authWrite401Streak = 0;
|
|
7861
|
+
const _AUTH_401_THRESHOLD = 3;
|
|
7517
7862
|
function handleAuthWrite(res) {
|
|
7518
7863
|
if (!res) return res;
|
|
7519
7864
|
if (res.status === 401) {
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
requestAccess().catch(() => {});
|
|
7530
|
-
} catch {}
|
|
7865
|
+
_authWrite401Streak++;
|
|
7866
|
+
console.warn(`[pypilot-newui] auth 401 (${_authWrite401Streak}/${_AUTH_401_THRESHOLD}) on:`, res.url);
|
|
7867
|
+
if (_authWrite401Streak >= _AUTH_401_THRESHOLD) {
|
|
7868
|
+
_authWrite401Streak = 0;
|
|
7869
|
+
try {
|
|
7870
|
+
setToken("");
|
|
7871
|
+
requestAccess().catch(() => {});
|
|
7872
|
+
} catch {}
|
|
7873
|
+
}
|
|
7531
7874
|
} else if (res.status < 400) {
|
|
7875
|
+
_authWrite401Streak = 0;
|
|
7532
7876
|
console.info(`[pypilot-newui] write ok ${res.status}:`, res.url);
|
|
7533
7877
|
} else {
|
|
7534
7878
|
console.warn(`[pypilot-newui] write failed ${res.status}:`, res.url);
|
|
@@ -8489,6 +8833,24 @@
|
|
|
8489
8833
|
try { await apDisengage(); console.info("empopado: AP disengaged (was OFF before empopado)"); }
|
|
8490
8834
|
catch (e) { console.warn("empopado: disengage restore failed", e); }
|
|
8491
8835
|
}
|
|
8836
|
+
} else {
|
|
8837
|
+
// Rev440 (Carlos 2026-10-10): salir dejando el pilot en "true wind"
|
|
8838
|
+
// es menos estable que compass para mantener el rumbo actual.
|
|
8839
|
+
// Conmutar a compass con target = heading actual: el barco se
|
|
8840
|
+
// queda EXACTAMENTE donde está pero bajo el modo seguro. Si no
|
|
8841
|
+
// hay sensor de heading, dejar como estaba.
|
|
8842
|
+
const curHdg = (typeof state.heading === "number") ? state.heading : null;
|
|
8843
|
+
if (curHdg != null) {
|
|
8844
|
+
try {
|
|
8845
|
+
await apSetModeAndWait("compass");
|
|
8846
|
+
await apSetTargetRad(curHdg);
|
|
8847
|
+
console.info(`empopado: safe-exit to compass @ ${(curHdg * RAD2DEG).toFixed(1)} deg (Rev440)`);
|
|
8848
|
+
} catch (e) {
|
|
8849
|
+
console.warn("empopado: safe-exit to compass failed", e);
|
|
8850
|
+
}
|
|
8851
|
+
} else {
|
|
8852
|
+
console.info("empopado: finish without restore (Rev407 default) — no heading sensor, leaving pilot where it is");
|
|
8853
|
+
}
|
|
8492
8854
|
}
|
|
8493
8855
|
// Auto-hide del summary igual que aproado (usa APROADO_SUMMARY_MS).
|
|
8494
8856
|
ap._summaryTimer = setTimeout(() => { empopadoTearDown(); }, APROADO_SUMMARY_MS);
|
|
@@ -8572,7 +8934,27 @@
|
|
|
8572
8934
|
catch (e) { console.warn("aproado: disengage failed", e); }
|
|
8573
8935
|
}
|
|
8574
8936
|
} else {
|
|
8575
|
-
|
|
8937
|
+
// Rev440 (Carlos 2026-10-10): "al salir queda en wind, mejor
|
|
8938
|
+
// compass por estabilidad de rumbo". aproadoStart puso el pilot
|
|
8939
|
+
// en modo "wind" para controlar AWA. Si no restauramos, el
|
|
8940
|
+
// barco se queda en wind indefinidamente — menos estable que
|
|
8941
|
+
// compass para mantener el rumbo actual. Fix: conmutar a
|
|
8942
|
+
// compass con target = heading actual, de modo que el pilot
|
|
8943
|
+
// sostenga EXACTAMENTE el rumbo donde quedó la maniobra pero
|
|
8944
|
+
// en el modo seguro. Si falta sensor de heading, dejar como
|
|
8945
|
+
// estaba (sin sacar al pilot de wind a ciegas).
|
|
8946
|
+
const curHdg = (typeof state.heading === "number") ? state.heading : null;
|
|
8947
|
+
if (curHdg != null) {
|
|
8948
|
+
try {
|
|
8949
|
+
await apSetModeAndWait("compass");
|
|
8950
|
+
await apSetTargetRad(curHdg);
|
|
8951
|
+
console.info(`aproado: safe-exit to compass @ ${(curHdg * RAD2DEG).toFixed(1)} deg (Rev440)`);
|
|
8952
|
+
} catch (e) {
|
|
8953
|
+
console.warn("aproado: safe-exit to compass failed", e);
|
|
8954
|
+
}
|
|
8955
|
+
} else {
|
|
8956
|
+
console.info("aproado: finish without restore (Rev415 default) — no heading sensor, leaving pilot where it is");
|
|
8957
|
+
}
|
|
8576
8958
|
}
|
|
8577
8959
|
apr._summaryTimer = setTimeout(() => {
|
|
8578
8960
|
if (state.aproado === apr) {
|
|
@@ -9625,6 +10007,56 @@
|
|
|
9625
10007
|
};
|
|
9626
10008
|
if (prevBtn) prevBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(-1); });
|
|
9627
10009
|
if (nextBtn) nextBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(1); });
|
|
10010
|
+
// Rev442 (Carlos QA Rev441): the X button hides the infobox and
|
|
10011
|
+
// clears the user-pick flag so new threats can take over again.
|
|
10012
|
+
const closeBtn = document.getElementById("ais-infobox-close");
|
|
10013
|
+
if (closeBtn) {
|
|
10014
|
+
closeBtn.addEventListener("click", (ev) => {
|
|
10015
|
+
ev.stopPropagation();
|
|
10016
|
+
state._aisInfoboxUserPicked = false;
|
|
10017
|
+
clearTimeout(state._aisInfoboxUserPickTimer);
|
|
10018
|
+
const box = document.getElementById("ais-infobox");
|
|
10019
|
+
if (box) box.hidden = true;
|
|
10020
|
+
});
|
|
10021
|
+
}
|
|
10022
|
+
// Rev441 (Carlos 2026-10-10): "si hacemos tap en la flecha del
|
|
10023
|
+
// target riesgo AIS, se abra su ventana de datos". Delegation on
|
|
10024
|
+
// the SVG layer: find the enclosing <g.ais-target> and open its
|
|
10025
|
+
// data in the infobox. Works for both pending and ACKed arrows.
|
|
10026
|
+
const aisLayer = document.getElementById("ais-alarm-layer");
|
|
10027
|
+
if (aisLayer) {
|
|
10028
|
+
aisLayer.addEventListener("click", (ev) => {
|
|
10029
|
+
ev.stopPropagation();
|
|
10030
|
+
let el = ev.target;
|
|
10031
|
+
while (el && el !== aisLayer && !(el.classList && el.classList.contains("ais-target"))) {
|
|
10032
|
+
el = el.parentNode;
|
|
10033
|
+
}
|
|
10034
|
+
if (!el || el === aisLayer) return;
|
|
10035
|
+
const mmsi = el.dataset && el.dataset.mmsi;
|
|
10036
|
+
if (!mmsi || !state.aisAlarms) return;
|
|
10037
|
+
const arr = Array.from(state.aisAlarms.values());
|
|
10038
|
+
arr.sort((a, b) => {
|
|
10039
|
+
const sa = a.value.state === "alarm" ? 0 : 1;
|
|
10040
|
+
const sb = b.value.state === "alarm" ? 0 : 1;
|
|
10041
|
+
if (sa !== sb) return sa - sb;
|
|
10042
|
+
const ta = Math.abs(a.value.data?.tcpa ?? 1e9);
|
|
10043
|
+
const tb = Math.abs(b.value.data?.tcpa ?? 1e9);
|
|
10044
|
+
return ta - tb;
|
|
10045
|
+
});
|
|
10046
|
+
const idx = arr.findIndex((a) => a.mmsi === mmsi);
|
|
10047
|
+
if (idx < 0) return;
|
|
10048
|
+
state._aisInfoboxIdx = idx;
|
|
10049
|
+
state._aisInfoboxUserPicked = true;
|
|
10050
|
+
renderAisAlarms();
|
|
10051
|
+
// Auto-reset the user-pick flag after 20 s so new threats
|
|
10052
|
+
// can take over the box afterwards.
|
|
10053
|
+
clearTimeout(state._aisInfoboxUserPickTimer);
|
|
10054
|
+
state._aisInfoboxUserPickTimer = setTimeout(() => {
|
|
10055
|
+
state._aisInfoboxUserPicked = false;
|
|
10056
|
+
try { renderAisAlarms(); } catch { /* silent */ }
|
|
10057
|
+
}, 20000);
|
|
10058
|
+
});
|
|
10059
|
+
}
|
|
9628
10060
|
} catch (e) { console.warn("[ais] ack wire:", e); }
|
|
9629
10061
|
}
|
|
9630
10062
|
|
|
@@ -13809,14 +14241,28 @@
|
|
|
13809
14241
|
let chipDownTs = 0;
|
|
13810
14242
|
let chipDownX = 0, chipDownY = 0;
|
|
13811
14243
|
let chipMoved = false;
|
|
14244
|
+
let chipLongPressTimer = null;
|
|
14245
|
+
let chipLongPressFired = false;
|
|
13812
14246
|
const CHIP_TAP_MAX_MS = 400;
|
|
14247
|
+
const CHIP_LONG_PRESS_MS = 550;
|
|
13813
14248
|
const CHIP_MOVE_TOLERANCE_PX = 8;
|
|
14249
|
+
const cancelLongPress = () => {
|
|
14250
|
+
if (chipLongPressTimer) { clearTimeout(chipLongPressTimer); chipLongPressTimer = null; }
|
|
14251
|
+
};
|
|
13814
14252
|
speedChip.addEventListener("pointerdown", (e) => {
|
|
13815
14253
|
if (e.button !== undefined && e.button !== 0) return;
|
|
13816
14254
|
chipDownTs = Date.now();
|
|
13817
14255
|
chipDownX = e.clientX;
|
|
13818
14256
|
chipDownY = e.clientY;
|
|
13819
14257
|
chipMoved = false;
|
|
14258
|
+
chipLongPressFired = false;
|
|
14259
|
+
cancelLongPress();
|
|
14260
|
+
chipLongPressTimer = setTimeout(() => {
|
|
14261
|
+
chipLongPressFired = true;
|
|
14262
|
+
chipLongPressTimer = null;
|
|
14263
|
+
try { if (navigator.vibrate) navigator.vibrate(15); } catch { /* silent */ }
|
|
14264
|
+
_openRoseChipSelectorModal();
|
|
14265
|
+
}, CHIP_LONG_PRESS_MS);
|
|
13820
14266
|
e.stopPropagation();
|
|
13821
14267
|
});
|
|
13822
14268
|
speedChip.addEventListener("pointermove", (e) => {
|
|
@@ -13825,17 +14271,23 @@
|
|
|
13825
14271
|
const dy = e.clientY - chipDownY;
|
|
13826
14272
|
if (Math.abs(dx) > CHIP_MOVE_TOLERANCE_PX || Math.abs(dy) > CHIP_MOVE_TOLERANCE_PX) {
|
|
13827
14273
|
chipMoved = true;
|
|
14274
|
+
cancelLongPress();
|
|
13828
14275
|
}
|
|
13829
14276
|
});
|
|
13830
14277
|
speedChip.addEventListener("pointerup", (e) => {
|
|
13831
14278
|
e.stopPropagation();
|
|
14279
|
+
cancelLongPress();
|
|
13832
14280
|
if (!chipDownTs) return;
|
|
13833
14281
|
const elapsed = Date.now() - chipDownTs;
|
|
13834
14282
|
chipDownTs = 0;
|
|
14283
|
+
if (chipLongPressFired) return; // selector already opened
|
|
13835
14284
|
if (chipMoved) return;
|
|
13836
14285
|
if (elapsed > CHIP_TAP_MAX_MS) return;
|
|
13837
|
-
|
|
14286
|
+
// Rev439/445: bottom chip cycles through the user-picked set.
|
|
14287
|
+
_cycleRoseBottomChip();
|
|
13838
14288
|
});
|
|
14289
|
+
speedChip.addEventListener("pointercancel", () => { cancelLongPress(); chipDownTs = 0; });
|
|
14290
|
+
speedChip.addEventListener("pointerleave", () => { cancelLongPress(); });
|
|
13839
14291
|
// Kill the synthesised click that follows pointer events so it
|
|
13840
14292
|
// cannot bubble up and trigger anything else on <main>.
|
|
13841
14293
|
speedChip.addEventListener("click", (e) => {
|
|
@@ -16261,5 +16713,7 @@
|
|
|
16261
16713
|
// AP mutations (engage / mode / target) do not silently 401 after
|
|
16262
16714
|
// the SK server restarts.
|
|
16263
16715
|
ensureAccessOnBoot().catch(() => {});
|
|
16716
|
+
// Rev446 (Carlos): wire fullscreen + keep-awake toggle.
|
|
16717
|
+
try { _wireFullscreenAndWakeLock(); } catch (e) { console.warn("[fs/wake] wire:", e); }
|
|
16264
16718
|
});
|
|
16265
16719
|
})();
|
package/public/index.html
CHANGED
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
the mode selector shows the current mode already. -->
|
|
52
52
|
|
|
53
53
|
<!-- Tabs -->
|
|
54
|
+
<!-- Rev446 (Carlos): fullscreen + keep-awake toggle. One tap enters
|
|
55
|
+
browser fullscreen (hides tabs, URL bar, status bar on mobile)
|
|
56
|
+
AND requests a screen Wake Lock so the display never dims nor
|
|
57
|
+
locks — same UX as YouTube's player. Second tap exits both. -->
|
|
58
|
+
<button type="button" id="fs-toggle" title="Pantalla completa / Keep awake" aria-label="Fullscreen">⛶</button>
|
|
54
59
|
<nav id="tabbar">
|
|
55
60
|
<button class="tab-btn active" data-tab="control" data-i18n="tab.control">Control</button>
|
|
56
61
|
<button class="tab-btn" data-tab="tune" data-i18n="tab.tune">Tune</button>
|
|
@@ -101,6 +106,10 @@
|
|
|
101
106
|
<button type="button" class="ais-infobox-nav-btn" id="ais-infobox-next" aria-label="Next">›</button>
|
|
102
107
|
</span>
|
|
103
108
|
</div>
|
|
109
|
+
<!-- Rev442 (Carlos QA Rev441): X to close the infobox when
|
|
110
|
+
it was opened by tapping an arrow (otherwise there was
|
|
111
|
+
no way to dismiss it). Sits in the head bar. -->
|
|
112
|
+
<button type="button" class="ais-infobox-close" id="ais-infobox-close" aria-label="Close">×</button>
|
|
104
113
|
<div class="ais-infobox-body" id="ais-infobox-body"></div>
|
|
105
114
|
<!-- Rev438: closing speed row (range/tcpa derived); hidden
|
|
106
115
|
when tcpa is zero or negative (target already passed). -->
|
|
@@ -146,14 +155,11 @@
|
|
|
146
155
|
<g id="rose-boat" transform="rotate(-90) scale(0.22)">
|
|
147
156
|
<image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
|
|
148
157
|
</g>
|
|
149
|
-
<!--
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
reliable, idempotent across SK deltas, and always on
|
|
155
|
-
top of every SVG layer regardless of DOM order. -->
|
|
156
|
-
<g id="ais-alarm-layer"></g>
|
|
158
|
+
<!-- Rev449 (Carlos): placeholder — the AIS alarm layer has
|
|
159
|
+
moved to the END of the SVG so AIS target icons paint
|
|
160
|
+
ON TOP of wind arrows, sectors and the countdown HUD
|
|
161
|
+
(SVG z-order follows DOM order). -->
|
|
162
|
+
|
|
157
163
|
<!-- Rev170/171 (Carlos): tack countdown overlay. Huge red
|
|
158
164
|
digit over the boat, clickable to cancel. Two phases:
|
|
159
165
|
phase "arming" → shows "5, 4, 3, 2, 1" (integer secs)
|
|
@@ -477,11 +483,16 @@
|
|
|
477
483
|
straddles the top of the compass ring (y=-100 to -84);
|
|
478
484
|
the raya's short 14-unit length is enough to peek
|
|
479
485
|
out just below the chip when target ≈ heading. -->
|
|
486
|
+
<!-- Rev444 (Carlos QA Rev443): chip grows UP (y goes more
|
|
487
|
+
negative) rather than down, so the second line does not
|
|
488
|
+
cover the target diamond that sits just below the top
|
|
489
|
+
of the ring. Bottom edge stays at y=-84 as before. -->
|
|
480
490
|
<g id="rose-hdg-chip">
|
|
481
|
-
<rect x="-
|
|
482
|
-
fill="rgba(0,0,0,0.
|
|
483
|
-
<text id="rose-hdg-value" x="0" y="-
|
|
484
|
-
<text id="rose-hdg-deg" x="0" y="-
|
|
491
|
+
<rect x="-22" y="-110" width="44" height="26" rx="4" ry="4"
|
|
492
|
+
fill="rgba(0,0,0,0.78)" stroke="#4a5867" stroke-width="1"/>
|
|
493
|
+
<text id="rose-hdg-value" x="0" y="-100" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">---</text>
|
|
494
|
+
<text id="rose-hdg-deg" x="0" y="-100" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="9" font-weight="700">°</text>
|
|
495
|
+
<text id="rose-hdg-prec" x="0" y="-89" text-anchor="middle" dominant-baseline="middle" fill="#8a98a8" font-size="9" font-weight="800" font-family="ui-monospace, monospace">--</text>
|
|
485
496
|
</g>
|
|
486
497
|
<!-- Rev216 (Carlos): rotation-sense indicator. A curved
|
|
487
498
|
arrow drawn just inside the compass ring, showing
|
|
@@ -499,7 +510,38 @@
|
|
|
499
510
|
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
500
511
|
<path d="M 0 0 L 10 5 L 0 10 Z" fill="#ff9500"/>
|
|
501
512
|
</marker>
|
|
513
|
+
<!-- Rev443 (Carlos QA Rev442): tint filters for AIS target
|
|
514
|
+
hull SVG. Each one replaces the per-pixel RGB of
|
|
515
|
+
boat-cenital.svg with a fixed solid colour, preserving
|
|
516
|
+
alpha. Result: the full top-view silhouette rendered in
|
|
517
|
+
red (alarm), orange (warn) or dim blue-grey (acked). -->
|
|
518
|
+
<filter id="ais-tint-alarm" x="0%" y="0%" width="100%" height="100%">
|
|
519
|
+
<feColorMatrix type="matrix" values="
|
|
520
|
+
0 0 0 0 1
|
|
521
|
+
0 0 0 0 0.23
|
|
522
|
+
0 0 0 0 0.19
|
|
523
|
+
0 0 0 1 0"/>
|
|
524
|
+
</filter>
|
|
525
|
+
<filter id="ais-tint-warn" x="0%" y="0%" width="100%" height="100%">
|
|
526
|
+
<feColorMatrix type="matrix" values="
|
|
527
|
+
0 0 0 0 0.96
|
|
528
|
+
0 0 0 0 0.65
|
|
529
|
+
0 0 0 0 0.14
|
|
530
|
+
0 0 0 1 0"/>
|
|
531
|
+
</filter>
|
|
532
|
+
<filter id="ais-tint-acked" x="0%" y="0%" width="100%" height="100%">
|
|
533
|
+
<feColorMatrix type="matrix" values="
|
|
534
|
+
0 0 0 0 0.42
|
|
535
|
+
0 0 0 0 0.48
|
|
536
|
+
0 0 0 0 0.56
|
|
537
|
+
0 0 0 1 0"/>
|
|
538
|
+
</filter>
|
|
502
539
|
</defs>
|
|
540
|
+
<!-- Rev449: AIS targets as the LAST element inside the rose
|
|
541
|
+
SVG, so they are the top-most layer above wind arrows,
|
|
542
|
+
sectors, boat, countdown HUD, heading chip and rotation
|
|
543
|
+
arc. Keeps the hull sprite always visible. -->
|
|
544
|
+
<g id="ais-alarm-layer"></g>
|
|
503
545
|
</svg>
|
|
504
546
|
<!-- Rev71: wind-speed chip promoted from SVG group to an HTML div
|
|
505
547
|
overlaid on the rose bottom-center. Text baseline aligned
|
|
@@ -2401,6 +2443,6 @@
|
|
|
2401
2443
|
</div>
|
|
2402
2444
|
</div>
|
|
2403
2445
|
|
|
2404
|
-
<script src="app.js?v=
|
|
2446
|
+
<script src="app.js?v=Rev449"></script>
|
|
2405
2447
|
</body>
|
|
2406
2448
|
</html>
|
package/public/style.css
CHANGED
|
@@ -207,6 +207,40 @@ body.nav-hidden nav#tabbar {
|
|
|
207
207
|
text-transform: uppercase;
|
|
208
208
|
letter-spacing: 0.3px;
|
|
209
209
|
}
|
|
210
|
+
/* Rev446 (Carlos): landscape layout has limited vertical space (tablet
|
|
211
|
+
mounted in cockpit) — the tabbar was eating ~44 px of precious height.
|
|
212
|
+
Trim padding and font so it occupies ~24 px instead. */
|
|
213
|
+
@media (orientation: landscape) {
|
|
214
|
+
.tab-btn {
|
|
215
|
+
padding: 4px 4px;
|
|
216
|
+
font-size: 12px;
|
|
217
|
+
letter-spacing: 0.2px;
|
|
218
|
+
border-bottom-width: 2px;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/* Rev446: fullscreen + keep-awake toggle. Floating top-right over
|
|
222
|
+
everything so it stays reachable from any tab. Icon flips when
|
|
223
|
+
fullscreen is active (⛶ → ⛶). Hides itself in fullscreen OPTIONAL
|
|
224
|
+
— we keep it visible so the sailor can exit. */
|
|
225
|
+
#fs-toggle {
|
|
226
|
+
position: fixed;
|
|
227
|
+
top: 4px;
|
|
228
|
+
right: 4px;
|
|
229
|
+
z-index: 9999;
|
|
230
|
+
width: 32px;
|
|
231
|
+
height: 32px;
|
|
232
|
+
padding: 0;
|
|
233
|
+
background: rgba(10, 14, 20, 0.72);
|
|
234
|
+
color: #cbd3dc;
|
|
235
|
+
border: 1px solid #4a5867;
|
|
236
|
+
border-radius: 6px;
|
|
237
|
+
font-size: 16px;
|
|
238
|
+
line-height: 1;
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
opacity: 0.7;
|
|
241
|
+
}
|
|
242
|
+
#fs-toggle:hover { opacity: 1; }
|
|
243
|
+
#fs-toggle.is-fullscreen { color: var(--accent, #4bc0ff); }
|
|
210
244
|
|
|
211
245
|
.tab-btn.active {
|
|
212
246
|
color: var(--fg);
|
|
@@ -554,11 +588,13 @@ main {
|
|
|
554
588
|
overflow: hidden;
|
|
555
589
|
text-overflow: clip;
|
|
556
590
|
}
|
|
591
|
+
/* Rev445 (Carlos QA Rev444): one more notch up for the AWS/TWS sub-line. */
|
|
557
592
|
.dash-rose .dash-cell .sub {
|
|
558
593
|
width: 100%;
|
|
559
|
-
font-size: clamp(
|
|
594
|
+
font-size: clamp(20px, 3.8vw, 32px) !important;
|
|
560
595
|
line-height: 1.1;
|
|
561
|
-
margin-top:
|
|
596
|
+
margin-top: 3px;
|
|
597
|
+
font-weight: 700;
|
|
562
598
|
}
|
|
563
599
|
/* Rev50: `!important` so the generic .dash-cell .value/.sub rules
|
|
564
600
|
defined earlier in this file (which centered everything) cannot win
|
|
@@ -4040,18 +4076,16 @@ body.pypilot-offline .pypilot-write {
|
|
|
4040
4076
|
of the self boat with name + CPA + TCPA + ACK button. The
|
|
4041
4077
|
notification source is signalk-collision-alerts (SK v2 Notifications
|
|
4042
4078
|
API); pypilot-newui only renders + routes the ACK. */
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
}
|
|
4052
|
-
.ais-target.ais-
|
|
4053
|
-
animation: ais-blink 900ms infinite;
|
|
4054
|
-
}
|
|
4079
|
+
/* Rev443: AIS target is a top-view <image> href="boat-cenital.svg"
|
|
4080
|
+
tinted by SVG filter (ais-tint-alarm / warn / acked). Only cursor,
|
|
4081
|
+
pointer-events and the alarm blink are driven by CSS; colour +
|
|
4082
|
+
scale live on the SVG element itself. */
|
|
4083
|
+
.ais-target {
|
|
4084
|
+
cursor: pointer; /* tap opens data popup */
|
|
4085
|
+
pointer-events: all;
|
|
4086
|
+
}
|
|
4087
|
+
.ais-target.ais-alarm { animation: ais-blink 900ms infinite; }
|
|
4088
|
+
.ais-target.ais-acked { animation: none !important; }
|
|
4055
4089
|
@keyframes ais-blink {
|
|
4056
4090
|
0%, 100% { opacity: 1; }
|
|
4057
4091
|
50% { opacity: 0.35; }
|
|
@@ -4128,6 +4162,35 @@ body.pypilot-offline .pypilot-write {
|
|
|
4128
4162
|
.ais-infobox-html.ais-warn .ais-infobox-ack { background: #f5a623; color: #1a1d22; }
|
|
4129
4163
|
.ais-infobox-ack:hover { filter: brightness(1.1); }
|
|
4130
4164
|
.ais-infobox-ack:active { transform: scale(0.95); }
|
|
4165
|
+
/* Rev442: ACKed state — same button, muted colours, not clickable. */
|
|
4166
|
+
.ais-infobox-ack.is-acked,
|
|
4167
|
+
.ais-infobox-ack[disabled] {
|
|
4168
|
+
background: #6b7a8f !important;
|
|
4169
|
+
color: #e8ecf2 !important;
|
|
4170
|
+
border-color: #a8b4c4 !important;
|
|
4171
|
+
cursor: default;
|
|
4172
|
+
opacity: 0.9;
|
|
4173
|
+
}
|
|
4174
|
+
.ais-infobox-ack.is-acked:active,
|
|
4175
|
+
.ais-infobox-ack[disabled]:active { transform: none; }
|
|
4176
|
+
/* Rev442: X close button on the infobox head. Lives top-right of the
|
|
4177
|
+
card so the sailor can dismiss a popup opened by tap on the hull. */
|
|
4178
|
+
.ais-infobox-close {
|
|
4179
|
+
position: absolute;
|
|
4180
|
+
top: 4px;
|
|
4181
|
+
right: 6px;
|
|
4182
|
+
width: 22px;
|
|
4183
|
+
height: 22px;
|
|
4184
|
+
padding: 0;
|
|
4185
|
+
background: transparent;
|
|
4186
|
+
color: #cbd3dc;
|
|
4187
|
+
border: none;
|
|
4188
|
+
font-size: 20px;
|
|
4189
|
+
line-height: 1;
|
|
4190
|
+
cursor: pointer;
|
|
4191
|
+
font-weight: 700;
|
|
4192
|
+
}
|
|
4193
|
+
.ais-infobox-close:hover { color: #fff; }
|
|
4131
4194
|
/* Rev438: multi-target navigator inside the head row. Compact buttons
|
|
4132
4195
|
+ fraction counter. Hidden by default when there's only one alarm. */
|
|
4133
4196
|
.ais-infobox-nav {
|