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 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
  ![Control - compass rose with concentric target + wind arrows](https://raw.githubusercontent.com/Aitonos/signalk-pypilot-newui/main/public/screenshots/01-control-rose.jpg)
@@ -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 = "Rev438";
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.0.0",
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 A I S",
1470
- "ais.voice.warn": "Aviso A I S",
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
- const d = (value && value.data) || {};
3653
- // Parse name the same way as the infobox.
3654
- let name = null;
3655
- try {
3656
- const m = /Collision risk:\s*([^,]+),/.exec(String(value && value.message || ""));
3657
- if (m) name = m[1].trim();
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
- // Build final sentence. Leave spaces for TTS prosody. Keep MMSI
3688
- // out of the voice entirely (Carlos's rule: say "objetivo" instead).
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
- if (!value || !isActive || locallyAcked || targetStationary) {
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 alarms) {
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
- const selfPos = (state.position && typeof state.position.latitude === "number")
3793
- ? state.position
3794
- : d.cpaPositions?.self;
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
- g.setAttribute("class", `ais-target ais-${a.value.state}`);
3806
- g.setAttribute("transform", `translate(${x.toFixed(1)},${y.toFixed(1)}) rotate(${(relDeg + 180).toFixed(1)})`);
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
- // Simple red triangle (reusing a stylised boat glyph would need a
3809
- // separate SVG asset; the triangle is clear enough and parpadea).
3810
- const poly = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
3811
- poly.setAttribute("points", "0,-10 6,8 -6,8");
3812
- g.appendChild(poly);
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
- box.hidden = false;
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
- // Rev65 / 2.0.2: bottom AWS/TWS readout now uses TWO separate text
4038
- // nodes (small dim label on top, big white number below) and follows
4039
- // `state.windCornerShow` so a tap on the wind corner also swaps the
4040
- // readout at the bottom of the rose. Falls back gracefully if the
4041
- // chosen source has no value yet.
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
- li.textContent = _modeLabel(v);
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
- console.warn(`[pypilot-newui] auth 401 on:`, res.url);
7521
- // Rev87: fire-and-forget a new SK Access Request instead of the old
7522
- // username/password modal. If the admin has already approved this
7523
- // clientId once (typical after first install), the server returns
7524
- // a fresh permanent token immediately and the modal never shows.
7525
- // Only when the admin has to approve for the first time will the
7526
- // access-request modal appear with instructions.
7527
- try {
7528
- setToken("");
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
- console.info("aproado: finish without restore (Rev415 default) — leaving pilot where it is");
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
- _toggleWindCornerShow();
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
- <!-- Rev431: AIS collision overlay triangles (red/orange) are
150
- painted inside the SVG so they rotate with the compass.
151
- The infobox + ACK button live as HTML absolutely-
152
- positioned above the SVG (see .ais-infobox-html in CSS
153
- and renderAisAlarms in app.js); keeps the ACK click
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="-20" y="-100" width="40" height="16" rx="4" ry="4"
482
- fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
483
- <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>
484
- <text id="rose-hdg-deg" x="0" y="-91" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="9" font-weight="700">°</text>
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=Rev438"></script>
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(11px, 2vw, 16px) !important;
594
+ font-size: clamp(20px, 3.8vw, 32px) !important;
560
595
  line-height: 1.1;
561
- margin-top: 2px;
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
- .ais-target polygon {
4044
- fill: #ff3b30;
4045
- stroke: #fff;
4046
- stroke-width: 1;
4047
- opacity: 0.95;
4048
- }
4049
- .ais-target.ais-warn polygon {
4050
- fill: #f5a623; /* orange for warn, red for alarm */
4051
- }
4052
- .ais-target.ais-alarm {
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 {