signalk-pypilot-newui 2.12.0 → 3.0.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,112 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0 — 2026-10-10 — AIS collision overlay + Freeboard-SK embedded
4
+
5
+ Rolls up Rev431 through Rev438 (8 revs). The headline feature of 3.0.0
6
+ is an AIS collision overlay on the compass rose and a Freeboard-SK
7
+ chart embedded in the rose box on double-tap. Pypilot-newui is a pure
8
+ CONSUMER of the external
9
+ [`signalk-collision-alerts`](https://github.com/dirkwa/signalk-collision-alerts)
10
+ plugin (by @dirkwa, the maintainer of @signalk/app-dock and
11
+ server-admin-ui) and of the Signal K v2 Notifications API — no
12
+ CPA/TCPA engine lives here. The decision rationale is captured in the
13
+ new memory `feedback_signalk_ecosystem_first.md`: Signal K is a
14
+ cooperative ecosystem; reinventing a well-maintained plugin is
15
+ anti-pattern. See `docs/_feature_ais_overlay.md` for the full
16
+ operator guide + install.
17
+
18
+ Carlos classified this bump as **major (3.0.0)** at the start of the
19
+ sprint: it adds a new optional external-plugin dependency, changes
20
+ double-tap-on-rose behaviour (was no-op; now opens Freeboard) and
21
+ introduces a user-facing concept — AIS alarms with ACK — that was
22
+ not present in the 2.x line.
23
+
24
+ ### Added
25
+
26
+ - **AIS collision overlay (Rev431 → Rev438)**. Subscribes to
27
+ `notifications.navigation.closestApproach.<mmsi>` emitted by
28
+ `signalk-collision-alerts`. For each active `warn` / `alarm`:
29
+ - **Red / orange triangle** painted on the compass circle at the
30
+ bearing from the self vessel to the target *as of now* (not the
31
+ CPA future projection — Rev436 fetches the target's current
32
+ position + SOG via `/signalk/v1/api/vessels/<ctx>/...` on each
33
+ delta with a 30 s cache).
34
+ - **Infobox** on top of the self boat with level badge
35
+ (`ALARM` / `WARN`), target name (or "objetivo" when the target
36
+ has no name yet — MMSI never shown or spoken), CPA · TCPA,
37
+ closing speed, ACK button, and when > 1 target is active a
38
+ `N/M` navigator with `‹` / `›`.
39
+ - **Voice**: short Spanish sentence built from the raw data, not
40
+ the plugin's English string (which TTS garbled into "ce-pe-a cero
41
+ coma cero cuatro ene eme"). Example: *"Alarma A I S, PIRATA DE
42
+ ONS, a 47 metros, ya"*.
43
+ - **ACK** button sends `POST /signalk/v2/api/notifications/<id>/acknowledge`
44
+ (so Freeboard + every SK consumer sees it) AND silences the MMSI
45
+ locally for 10 min — survives the plugin re-publishing the same
46
+ threat with a fresh UUID.
47
+ - **"Silenciadas…" modal** (Rev438) lists every locally-silenced
48
+ MMSI with remaining minutes + per-row *Reactivar* + bulk *Reactivar
49
+ todas*.
50
+ - **Stationary filter** (Rev436): targets whose cached SOG < 0.15 kn
51
+ are dropped from the UI even if the plugin keeps emitting the
52
+ alarm. Mandatory in a Ría with dozens of moored vessels.
53
+ - **Overlay-card in Setup → ROSE OVERLAYS**: master on/off, voice
54
+ sub-toggle, link to the plugin's config page, access to the
55
+ silenced modal.
56
+ - **Freeboard-SK embedded in the rose box** (Rev432 → Rev433). Double-
57
+ tap `#wind-rose` opens `/@signalk/freeboard-sk/` as an iframe
58
+ positioned inside the compass square so the side panels (mode,
59
+ nudges, AP) stay visible. Chrome row has ⤢ Maximize (full
60
+ viewport) / ⤡ Restore / × Close. Iframe `src` cleared on close so
61
+ Freeboard releases its websocket.
62
+
63
+ ### Changed
64
+
65
+ - No breaking API changes to the pypilot-newui plugin itself —
66
+ everything existing in 2.12.0 still works identically. The 3.0.0
67
+ bump reflects the new user-facing concepts (AIS alarms, Freeboard
68
+ entry point) and the optional external-plugin dependency.
69
+
70
+ ### Fixed
71
+
72
+ Bugs found and fixed during the AIS sprint, each informed by Carlos's
73
+ QA notes and the real SK bus on Tunatunes:
74
+ - **ACK button did not close the infobox** (Rev436). Was keyed by
75
+ notification UUID (Rev434); plugin cycles the id while the threat
76
+ persists → infobox resurrected on the next delta. Now keyed by
77
+ MMSI with a 10-minute TTL.
78
+ - **Triangle 180° off** (Rev436). Rev431 pointed at
79
+ `data.cpaPositions.target` which is the *projected* position at the
80
+ CPA future instant — hence "me avisa babor cuando el target me
81
+ viene por aleta estribor". Now uses the current self + target
82
+ positions fetched from the SK REST API.
83
+ - **Multiple alarms, including moored vessels** (Rev436). Added the
84
+ stationary SOG < 0.15 kn drop filter so a Ría with 30 moored boats
85
+ doesn't fire false alarms through the plugin's own thresholds.
86
+ - **TTS garble on Freeboard open** (Rev436). Removed the
87
+ `_alSpeak("abriendo carta")` that the English TTS read as "ICE
88
+ open chat".
89
+ - **Overlay-card nested checkbox hidden under the master** (Rev437).
90
+ CSS `.overlay-card input[type=checkbox]` matched every checkbox and
91
+ stacked them at top-right. Scoped to direct child so the AIS
92
+ card's "Voice alert" sub-toggle renders in-flow.
93
+
94
+ ### Known limitations / upstream work
95
+
96
+ - **Upstream `signalk-collision-alerts` issue #17** —
97
+ https://github.com/dirkwa/signalk-collision-alerts/issues/17 — the
98
+ admin UI does not render the `Alert sensitivity` / `Level`
99
+ dropdowns because the plugin's typebox schema emits `anyOf+const`
100
+ instead of `enum`. We filed the issue with a fix proposal; the
101
+ workaround is to hand-edit the plugin JSON config or patch the
102
+ built `config.js` locally. See `docs/_feature_ais_overlay.md`.
103
+ - **Needs sea-trial QA** on real moving traffic with mixed `warn` /
104
+ `alarm` targets. Default sensible config documented
105
+ (preset: `harbour`, `maxRange`: 3 NM) for a Ría-style navigation
106
+ env; switch to `coastal` / `offshore` as needed.
107
+
108
+ ---
109
+
3
110
  ## 2.12.0 — 2026-10-08 — Virtual-tack audit cycle + Profile UX overhaul + safety aproado/empopado
4
111
 
5
112
  Rolls up Rev410 through Rev430 (21 revs). Driven by two sea trials
package/README.md CHANGED
@@ -235,6 +235,37 @@ pypilot version, restart pypilot_web).
235
235
 
236
236
  **Webapp URL**: `http://<sk-host>:3000/signalk-pypilot-newui/`
237
237
 
238
+ ## Works well with
239
+
240
+ Pypilot-newui is built as a well-behaved citizen of the Signal K
241
+ ecosystem — it consumes other mature plugins and standard APIs instead
242
+ of reinventing them. The sections below describe the plugins we
243
+ integrate with; none are hard dependencies (the autopilot visor works
244
+ on its own), but installing them unlocks the features listed.
245
+
246
+ - **[`@signalk/freeboard-sk`](https://www.npmjs.com/package/@signalk/freeboard-sk)**
247
+ — Double-tap the compass rose to open Freeboard-SK embedded inside
248
+ the rose box (side panels stay visible and usable). Lets you drop
249
+ from the autopilot view to the full chart with every AIS target in
250
+ one tap, without leaving the pypilot-newui session.
251
+ See `docs/_feature_ais_overlay.md`.
252
+
253
+ - **[`signalk-collision-alerts`](https://www.npmjs.com/package/signalk-collision-alerts)**
254
+ by @dirkwa — Provides the CPA/TCPA engine behind our AIS collision
255
+ overlay. Pypilot-newui subscribes to
256
+ `notifications.navigation.closestApproach.*` and renders a red /
257
+ orange triangle on the rose + an infobox with ACK that routes
258
+ through the Signal K v2 Notifications API, so an ACK from
259
+ pypilot-newui is also seen by Freeboard-SK and every other SK
260
+ client.
261
+ See `docs/_feature_ais_overlay.md`.
262
+
263
+ - **[`pypilot-autopilot-provider`](https://www.npmjs.com/package/pypilot-autopilot-provider)**
264
+ by Panaaj — Shares the single pypilot socket cleanly with our
265
+ autopilot provider (see next section) so KIP, Freeboard's Autopilot
266
+ Console and any other SK v2 Autopilot API client all drive the same
267
+ pilot without duplicate connections.
268
+
238
269
  ## Working alongside `pypilot-autopilot-provider`
239
270
 
240
271
  We build on top of the excellent
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 = "Rev430";
31
+ const PLUGIN_REVISION = "Rev438";
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": "2.12.0",
3
+ "version": "3.0.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": {
@@ -15,12 +15,19 @@
15
15
  "signalk-webapp",
16
16
  "signalk-category-instruments",
17
17
  "signalk-category-utility",
18
+ "signalk-category-ais",
19
+ "signalk-category-notifications",
18
20
  "pypilot",
19
21
  "tinypilot",
20
22
  "autopilot",
21
23
  "steering",
22
24
  "kip",
23
- "mobile-first"
25
+ "mobile-first",
26
+ "ais",
27
+ "cpa",
28
+ "tcpa",
29
+ "collision-avoidance",
30
+ "freeboard-sk-companion"
24
31
  ],
25
32
  "author": {
26
33
  "name": "Aitonos",
package/public/app.js CHANGED
@@ -584,6 +584,27 @@
584
584
  "dash.wind.twa": "TWA (true, magenta)",
585
585
  "dash.overlays.title": "ROSE OVERLAYS",
586
586
  "dash.overlays.hint": "Toggle what floats on the compass rose. Each card lists what data it needs to render.",
587
+ "dash.overlay.ais.title": "AIS collision alarm",
588
+ "dash.overlay.ais.needs": "Needs signalk-collision-alerts plugin installed in your SK server. Reads notifications.navigation.closestApproach.* and renders a red/orange triangle on the rose with an infobox + ACK. Double-tap the rose to open Freeboard-SK embedded.",
589
+ "dash.overlay.ais.config": "Open plugin config",
590
+ "dash.overlay.ais.voice": "Voice alert",
591
+ "ais.objetivo": "target",
592
+ "ais.voice.alarm": "AIS alarm",
593
+ "ais.voice.warn": "AIS warning",
594
+ "ais.voice.at": "at",
595
+ "ais.voice.metres": "metres",
596
+ "ais.voice.miles": "miles",
597
+ "ais.voice.in": "in",
598
+ "ais.voice.minute": "minute",
599
+ "ais.voice.minutes": "minutes",
600
+ "ais.voice.now": "now",
601
+ "ais.closing": "Closing",
602
+ "ais.silenced.title": "Silenced AIS alarms",
603
+ "ais.silenced.clearAll": "Unsilence all",
604
+ "ais.silenced.close": "Close",
605
+ "ais.silenced.empty": "No AIS alarms silenced right now.",
606
+ "ais.silenced.unsilence": "Unsilence",
607
+ "dash.overlay.ais.silenced": "Silenced…",
587
608
  "dash.overlay.awa.title": "A · Apparent wind",
588
609
  "dash.overlay.awa.needs": "Amber arrow · needs a wind sensor.",
589
610
  "dash.overlay.twa.title": "T · True wind",
@@ -1440,6 +1461,27 @@
1440
1461
  "dash.wind.twa": "TWA (real, magenta)",
1441
1462
  "dash.overlays.title": "CAPAS SOBRE LA ROSA",
1442
1463
  "dash.overlays.hint": "Activa las que quieras ver sobre la rosa. Cada tarjeta indica qué datos necesita.",
1464
+ "dash.overlay.ais.title": "Alarma de colisión AIS",
1465
+ "dash.overlay.ais.needs": "Requiere el plugin signalk-collision-alerts instalado en tu servidor SK. Lee notifications.navigation.closestApproach.* y pinta un triángulo rojo/naranja sobre la rosa con infobox + ACK. Doble tap en la rosa abre Freeboard-SK embebido.",
1466
+ "dash.overlay.ais.config": "Abrir ajustes del plugin",
1467
+ "dash.overlay.ais.voice": "Aviso de voz",
1468
+ "ais.objetivo": "objetivo",
1469
+ "ais.voice.alarm": "Alarma A I S",
1470
+ "ais.voice.warn": "Aviso A I S",
1471
+ "ais.voice.at": "a",
1472
+ "ais.voice.metres": "metros",
1473
+ "ais.voice.miles": "millas",
1474
+ "ais.voice.in": "en",
1475
+ "ais.voice.minute": "minuto",
1476
+ "ais.voice.minutes": "minutos",
1477
+ "ais.voice.now": "ya",
1478
+ "ais.closing": "Acercándose",
1479
+ "ais.silenced.title": "Alarmas AIS silenciadas",
1480
+ "ais.silenced.clearAll": "Reactivar todas",
1481
+ "ais.silenced.close": "Cerrar",
1482
+ "ais.silenced.empty": "Ninguna alarma silenciada ahora mismo.",
1483
+ "ais.silenced.unsilence": "Reactivar",
1484
+ "dash.overlay.ais.silenced": "Silenciadas…",
1443
1485
  "dash.overlay.awa.title": "A · Viento aparente",
1444
1486
  "dash.overlay.awa.needs": "Flecha amarilla · necesita sensor de viento.",
1445
1487
  "dash.overlay.twa.title": "T · Viento real",
@@ -2487,6 +2529,16 @@
2487
2529
  "performance.gybeAngleVelocityMadeGood",
2488
2530
  "performance.*",
2489
2531
  "environment.water.temperature",
2532
+ // Rev431 (Carlos sea trial follow-up, feature #9): AIS collision
2533
+ // alarms raised by `signalk-collision-alerts` via the SK v2
2534
+ // Notifications API land at
2535
+ // notifications.navigation.closestApproach.<mmsi>
2536
+ // under vessels.self. Wildcard subscribe picks every new target
2537
+ // without having to list MMSIs. Pypilot-newui is a CONSUMER —
2538
+ // the engine, thresholds and lifecycle (silence/acknowledge/
2539
+ // clear) all live in collision-alerts; we only render + voice +
2540
+ // relay the ACK button to the SK v2 endpoint.
2541
+ "notifications.navigation.closestApproach.*",
2490
2542
  ];
2491
2543
  const sub = {
2492
2544
  context: "vessels.self",
@@ -3004,6 +3056,16 @@
3004
3056
  case "performance.gybeAngle": state.gybeAngle = numericOrNull(value); break;
3005
3057
  case "performance.gybeAngleVelocityMadeGood": state.gybeVmg = numericOrNull(value); break;
3006
3058
  case "environment.water.temperature": state.waterTempK = numericOrNull(value); break;
3059
+ default: {
3060
+ // Rev431: AIS collision notifications from signalk-collision-alerts.
3061
+ // Path = notifications.navigation.closestApproach.<mmsi>. Dispatch
3062
+ // handled here to avoid hard-coding every possible MMSI in the
3063
+ // switch above.
3064
+ if (typeof path === "string" && path.startsWith("notifications.navigation.closestApproach.")) {
3065
+ _handleAisAlarmDelta(path, value);
3066
+ }
3067
+ break;
3068
+ }
3007
3069
  }
3008
3070
  }
3009
3071
 
@@ -3483,6 +3545,423 @@
3483
3545
  }
3484
3546
  }
3485
3547
 
3548
+ // Rev431 (Carlos sea trial follow-up, feature #9): AIS collision
3549
+ // overlay. We are a CONSUMER of signalk-collision-alerts; the engine
3550
+ // (CPA/TCPA/thresholds/lifecycle) lives in that plugin. We subscribe
3551
+ // to notifications.navigation.closestApproach.<mmsi> and:
3552
+ // - render a small red boat on the compass circle pointing to the
3553
+ // threat's bearing, blinking,
3554
+ // - show an infobox over #rose-boat with name + CPA + TCPA + ACK,
3555
+ // - speak the message once per state transition into warn/alarm,
3556
+ // - route ACK taps to POST /signalk/v2/api/notifications/<id>/acknowledge,
3557
+ // - double-tap on #wind-rose opens Freeboard-SK for the full chart.
3558
+ if (!state.aisAlarms) state.aisAlarms = new Map(); // mmsi -> { value, mmsi, posNow?, sogNow? }
3559
+ // Rev436 (Carlos QA Rev435): silence by MMSI with a TTL instead of
3560
+ // by notification UUID. The plugin keeps republishing the alarm as
3561
+ // long as the threat persists; keying by UUID worked for the first
3562
+ // delta but the plugin's next PUT may carry a new id and the
3563
+ // infobox resurrected. Map<mmsi, expireAtMs>; 10 minutes default.
3564
+ if (!state.aisLocalAckedMmsis) state.aisLocalAckedMmsis = new Map();
3565
+ const AIS_ACK_TTL_MS = 10 * 60 * 1000;
3566
+ function _aisIsMmsiAcked(mmsi) {
3567
+ const exp = state.aisLocalAckedMmsis.get(mmsi);
3568
+ if (!exp) return false;
3569
+ if (Date.now() > exp) { state.aisLocalAckedMmsis.delete(mmsi); return false; }
3570
+ return true;
3571
+ }
3572
+ // Rev436: per-target position/SOG cache populated from the SK v1
3573
+ // REST API on first sight of each alarm. Keyed by mmsi. Used both
3574
+ // for the "where does the threat come from NOW" bearing render
3575
+ // (cpaPositions.target is the FUTURE position at CPA time, which
3576
+ // Carlos saw as "le avisa babor cuando está en aleta estribor")
3577
+ // and for the "ignore stationary targets" filter.
3578
+ if (!state.aisTargetPos) state.aisTargetPos = new Map();
3579
+ const AIS_TARGET_POS_FRESH_MS = 30 * 1000;
3580
+ const AIS_IGNORE_SOG_MS = 0.08; // ~0.15 kn → stationary / moored
3581
+ async function _aisRefreshTargetPos(mmsi) {
3582
+ try {
3583
+ const now = Date.now();
3584
+ const cur = state.aisTargetPos.get(mmsi);
3585
+ if (cur && (now - cur.fetchedAt) < AIS_TARGET_POS_FRESH_MS) return cur;
3586
+ const ctx = `urn:mrn:imo:mmsi:${mmsi}`;
3587
+ const [posR, sogR] = await Promise.all([
3588
+ skFetch(`/signalk/v1/api/vessels/${encodeURIComponent(ctx)}/navigation/position/value`),
3589
+ skFetch(`/signalk/v1/api/vessels/${encodeURIComponent(ctx)}/navigation/speedOverGround/value`).catch(() => null),
3590
+ ]);
3591
+ let pos = null, sog = null;
3592
+ if (posR && posR.ok) pos = await posR.json().catch(() => null);
3593
+ if (sogR && sogR.ok) sog = await sogR.json().catch(() => null);
3594
+ const rec = {
3595
+ pos: (pos && typeof pos.latitude === "number") ? pos : null,
3596
+ sog: (typeof sog === "number") ? sog : null,
3597
+ fetchedAt: now,
3598
+ };
3599
+ state.aisTargetPos.set(mmsi, rec);
3600
+ try { renderAisAlarms(); } catch { /* silent */ }
3601
+ return rec;
3602
+ } catch { return null; }
3603
+ }
3604
+ // Rev434 (Carlos QA Rev433): toggles for the AIS overlay. Both default
3605
+ // ON so the feature works out of the box; sailor can disable from the
3606
+ // "AIS collision alarm" overlay-card in the rose overlays modal.
3607
+ const AIS_ALARM_LS_KEY = "pypilotnewui.aisAlarm";
3608
+ const AIS_ALARM_VOICE_LS_KEY = "pypilotnewui.aisAlarmVoice";
3609
+ function _aisAlarmEnabled() {
3610
+ try {
3611
+ const v = localStorage.getItem(AIS_ALARM_LS_KEY);
3612
+ return v == null ? true : v === "true";
3613
+ } catch { return true; }
3614
+ }
3615
+ function _aisAlarmSetEnabled(on) {
3616
+ try { localStorage.setItem(AIS_ALARM_LS_KEY, on ? "true" : "false"); } catch { /* silent */ }
3617
+ // Clear everything when disabled, so a stale triangle does not
3618
+ // linger on the rose until the next delta.
3619
+ if (!on) {
3620
+ try { state.aisAlarms.clear(); renderAisAlarms(); } catch { /* silent */ }
3621
+ }
3622
+ }
3623
+ function _aisAlarmVoiceEnabled() {
3624
+ try {
3625
+ const v = localStorage.getItem(AIS_ALARM_VOICE_LS_KEY);
3626
+ return v == null ? true : v === "true";
3627
+ } catch { return true; }
3628
+ }
3629
+ function _aisAlarmSetVoiceEnabled(on) {
3630
+ try { localStorage.setItem(AIS_ALARM_VOICE_LS_KEY, on ? "true" : "false"); } catch { /* silent */ }
3631
+ }
3632
+ function _mmsiFromAlarmPath(path) {
3633
+ const prefix = "notifications.navigation.closestApproach.";
3634
+ return path.startsWith(prefix) ? path.slice(prefix.length) : null;
3635
+ }
3636
+ function _mmsiFromAlarmValue(v) {
3637
+ try {
3638
+ const ref = v?.data?.targetRef || "";
3639
+ const m = /urn:mrn:imo:mmsi:(\d+)$/.exec(ref);
3640
+ return m ? m[1] : null;
3641
+ } catch { return null; }
3642
+ }
3643
+ // Rev435 (Carlos QA Rev434): "el mensaje de voz dice cosas raras,
3644
+ // quizás debamos darle los textos". The plugin's value.message is
3645
+ // English + technical ("Collision risk: PIRATA DE ONS, CPA 0.04 NM
3646
+ // in 0 min") and TTS reads it as "ce-pe-a cero coma cero cuatro ene
3647
+ // eme" which is unintelligible on a boat. We now build the voice
3648
+ // string ourselves from the raw data (cpa in metres, tcpa in
3649
+ // seconds, parsed name) + a short "alarma/aviso" prefix tied to the
3650
+ // notification level. i18n keys cover ES/EN.
3651
+ 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
+ }
3684
+ const kind = (newState === "alarm")
3685
+ ? (t("ais.voice.alarm") || "Alarma A I S")
3686
+ : (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(" ");
3693
+ }
3694
+ function _handleAisAlarmDelta(path, value) {
3695
+ // Rev434: respect the "AIS collision alarm" overlay toggle. Still
3696
+ // keep the state.aisAlarms Map in sync so the first ACK after a
3697
+ // re-enable finds the real id — but skip voice + render.
3698
+ const enabled = _aisAlarmEnabled();
3699
+ const mmsi = _mmsiFromAlarmPath(path) || _mmsiFromAlarmValue(value);
3700
+ if (!mmsi) return;
3701
+ const prev = state.aisAlarms.get(mmsi);
3702
+ const prevState = prev && prev.value && prev.value.state;
3703
+ const newState = value && value.state;
3704
+ const isActive = newState === "warn" || newState === "alarm";
3705
+ // Rev436: silence by MMSI with TTL — the plugin keeps republishing
3706
+ // the alarm (sometimes with a fresh UUID) while the threat
3707
+ // persists; a UUID-only match (Rev434) let it resurrect.
3708
+ const locallyAcked = _aisIsMmsiAcked(mmsi);
3709
+ // Rev436: filter "stationary" targets (moored boats in a port,
3710
+ // anchored vessels). SOG is fetched into state.aisTargetPos via
3711
+ // the SK v1 REST API; if we know SOG and it's below the threshold
3712
+ // the alarm is treated as "silent info".
3713
+ const cached = state.aisTargetPos.get(mmsi);
3714
+ const targetStationary = cached && typeof cached.sog === "number" && cached.sog < AIS_IGNORE_SOG_MS;
3715
+ if (!value || !isActive || locallyAcked || targetStationary) {
3716
+ state.aisAlarms.delete(mmsi);
3717
+ } else {
3718
+ state.aisAlarms.set(mmsi, { value, mmsi });
3719
+ // Fire a background refresh of the target's current position and
3720
+ // SOG so the bearing render uses "where it is now" instead of
3721
+ // cpaPositions.target (= future position at CPA time).
3722
+ _aisRefreshTargetPos(mmsi).catch(() => {});
3723
+ }
3724
+ try {
3725
+ if (enabled && _aisAlarmVoiceEnabled()
3726
+ && isActive && newState !== prevState
3727
+ && !locallyAcked
3728
+ && !(value.status && value.status.acknowledged)) {
3729
+ _alSpeak(_aisVoiceMessage(value, mmsi, newState));
3730
+ }
3731
+ } catch { /* silent */ }
3732
+ if (enabled) {
3733
+ try { renderAisAlarms(); } catch (e) { console.warn("[ais] render:", e); }
3734
+ } else {
3735
+ // Make sure stale UI is cleared when the feature is off.
3736
+ try { const box = document.getElementById("ais-infobox"); if (box) box.hidden = true; } catch { /* silent */ }
3737
+ try { const layer = document.getElementById("ais-alarm-layer"); if (layer) layer.textContent = ""; } catch { /* silent */ }
3738
+ }
3739
+ }
3740
+ function _aisBearingDeg(selfPos, targetPos) {
3741
+ if (!selfPos || !targetPos) return null;
3742
+ const toRad = (d) => d * Math.PI / 180;
3743
+ const φ1 = toRad(selfPos.latitude);
3744
+ const φ2 = toRad(targetPos.latitude);
3745
+ const Δλ = toRad(targetPos.longitude - selfPos.longitude);
3746
+ const y = Math.sin(Δλ) * Math.cos(φ2);
3747
+ const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
3748
+ const θ = Math.atan2(y, x);
3749
+ const brgDeg = (θ * 180 / Math.PI + 360) % 360;
3750
+ return brgDeg;
3751
+ }
3752
+ function _aisRelativeBearingDeg(selfPos, targetPos, selfHeadingRad) {
3753
+ const absBrg = _aisBearingDeg(selfPos, targetPos);
3754
+ if (absBrg == null) return null;
3755
+ const headDeg = (selfHeadingRad != null) ? (selfHeadingRad * RAD2DEG) : 0;
3756
+ let rel = absBrg - headDeg;
3757
+ while (rel < -180) rel += 360;
3758
+ while (rel > 180) rel -= 360;
3759
+ return rel;
3760
+ }
3761
+ function renderAisAlarms() {
3762
+ const layer = document.getElementById("ais-alarm-layer");
3763
+ if (!layer) return;
3764
+ layer.textContent = "";
3765
+ const alarms = Array.from(state.aisAlarms.values());
3766
+ if (alarms.length === 0) {
3767
+ const box = document.getElementById("ais-infobox");
3768
+ if (box) box.hidden = true;
3769
+ return;
3770
+ }
3771
+ // Rank most-urgent first (lowest TCPA first; alarms before warns).
3772
+ alarms.sort((a, b) => {
3773
+ const sa = a.value.state === "alarm" ? 0 : 1;
3774
+ const sb = b.value.state === "alarm" ? 0 : 1;
3775
+ if (sa !== sb) return sa - sb;
3776
+ const ta = Math.abs(a.value.data?.tcpa ?? 1e9);
3777
+ const tb = Math.abs(b.value.data?.tcpa ?? 1e9);
3778
+ return ta - tb;
3779
+ });
3780
+ // Draw one small red boat per active threat. r = 92 lands on the
3781
+ // outer edge of the compass circle (SVG viewBox -100 -110 200 220).
3782
+ const R = 92;
3783
+ for (const a of alarms) {
3784
+ const d = a.value.data || {};
3785
+ // Rev436 (Carlos QA Rev435): use NOW positions, not cpaPositions
3786
+ // (which are the projected positions at the CPA future instant
3787
+ // — bearing to that point tells you where you'll meet, not
3788
+ // where the threat is coming from). Prefer our own self position
3789
+ // + the target's position cached from the SK REST API. Fallback
3790
+ // to cpaPositions if the cache has not landed yet.
3791
+ const cached = state.aisTargetPos.get(a.mmsi);
3792
+ const selfPos = (state.position && typeof state.position.latitude === "number")
3793
+ ? state.position
3794
+ : d.cpaPositions?.self;
3795
+ const tgtPos = (cached && cached.pos) || d.cpaPositions?.target;
3796
+ const relDeg = _aisRelativeBearingDeg(selfPos, tgtPos, state.heading);
3797
+ if (relDeg == null) continue;
3798
+ const rad = (relDeg - 90) * Math.PI / 180; // SVG 0deg = east, we want 0deg = up
3799
+ const x = R * Math.cos(rad);
3800
+ const y = R * Math.sin(rad);
3801
+ // Pointing INWARD toward the self: rotate the triangle so its tip
3802
+ // points to (0,0). The triangle is drawn pointing up (0,-h), so
3803
+ // we rotate by relDeg + 180 to flip.
3804
+ 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)})`);
3807
+ 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);
3813
+ layer.appendChild(g);
3814
+ }
3815
+ // Rev432: infobox + ACK as a persistent HTML div.
3816
+ // Rev438: multi-target navigator — _aisInfoboxIdx is clamped to the
3817
+ // sorted alarms array so Carlos can prev/next through them.
3818
+ const box = document.getElementById("ais-infobox");
3819
+ if (!box) return;
3820
+ if (typeof state._aisInfoboxIdx !== "number") state._aisInfoboxIdx = 0;
3821
+ if (state._aisInfoboxIdx >= alarms.length) state._aisInfoboxIdx = 0;
3822
+ const top = alarms[state._aisInfoboxIdx];
3823
+ const d = top.value.data || {};
3824
+ const cpaNm = (d.cpa != null) ? (d.cpa / 1852) : null;
3825
+ const tcpaMin = (d.tcpa != null) ? (d.tcpa / 60) : null;
3826
+ // Rev438: closing speed derived from range (m) and tcpa (s).
3827
+ // Positive only — if tcpa <= 0 the target has already passed and
3828
+ // we hide the row.
3829
+ let closingKn = null;
3830
+ if (typeof d.range === "number" && typeof d.tcpa === "number" && d.tcpa > 0) {
3831
+ const mps = d.range / d.tcpa;
3832
+ closingKn = mps * 1.94384;
3833
+ }
3834
+ // Rev434 (Carlos QA Rev433): "si solo hay MMSI, entonces obviar el
3835
+ // numero MMSI y decir objetivo". The collision-alerts message
3836
+ // follows "Collision risk: NAME, ...". If NAME equals the MMSI
3837
+ // (plugin fallback when the AIS target has no name yet) we show
3838
+ // "objetivo" instead of the long numeric string.
3839
+ let name = null;
3840
+ try {
3841
+ const m = /Collision risk:\s*([^,]+),/.exec(String(top.value.message || ""));
3842
+ if (m) name = m[1].trim();
3843
+ } catch { /* silent */ }
3844
+ if (!name || name === top.mmsi || /^\d{6,}$/.test(name)) {
3845
+ name = t("ais.objetivo") || "objetivo";
3846
+ }
3847
+ const lvl = (top.value.state === "alarm") ? "ALARM" : "WARN";
3848
+ box.classList.remove("ais-warn", "ais-alarm");
3849
+ box.classList.add(`ais-${top.value.state}`);
3850
+ box.dataset.notifId = top.value.id || "";
3851
+ box.dataset.mmsi = top.mmsi;
3852
+ const lvlEl = document.getElementById("ais-infobox-level");
3853
+ const nameEl = document.getElementById("ais-infobox-name");
3854
+ const bodyEl = document.getElementById("ais-infobox-body");
3855
+ if (lvlEl) lvlEl.textContent = lvl;
3856
+ if (nameEl) nameEl.textContent = String(name).slice(0, 22);
3857
+ if (bodyEl) {
3858
+ const cpaStr = cpaNm != null ? cpaNm.toFixed(2) + " NM" : "—";
3859
+ const tcpaStr = tcpaMin != null ? Math.round(tcpaMin) + " min" : "—";
3860
+ bodyEl.textContent = `CPA ${cpaStr} · ${tcpaStr}`;
3861
+ }
3862
+ // Rev438: multi-target nav (hidden when only 1 alarm active).
3863
+ const navEl = document.getElementById("ais-infobox-nav");
3864
+ const navCount = document.getElementById("ais-infobox-nav-count");
3865
+ if (navEl) navEl.hidden = alarms.length <= 1;
3866
+ if (navCount) navCount.textContent = `${state._aisInfoboxIdx + 1}/${alarms.length}`;
3867
+ // Rev438: closing speed row.
3868
+ const closingEl = document.getElementById("ais-infobox-closing");
3869
+ if (closingEl) {
3870
+ if (closingKn != null) {
3871
+ closingEl.textContent = `${t("ais.closing") || "Acercándose"}: ${closingKn.toFixed(1)} kn`;
3872
+ closingEl.hidden = false;
3873
+ } else {
3874
+ closingEl.hidden = true;
3875
+ }
3876
+ }
3877
+ box.hidden = false;
3878
+ }
3879
+ // Rev438: lightweight modal to list silenced MMSIs with un-silence
3880
+ // per entry + "un-silence all". Uses _pmPrompt-style elements.
3881
+ function _aisShowSilencedModal() {
3882
+ const now = Date.now();
3883
+ // Compact a snapshot of valid (non-expired) entries.
3884
+ const rows = [];
3885
+ for (const [mmsi, exp] of state.aisLocalAckedMmsis.entries()) {
3886
+ if (exp > now) rows.push({ mmsi, remainMin: Math.max(0, Math.round((exp - now) / 60000)) });
3887
+ }
3888
+ let m = document.getElementById("ais-silenced-modal");
3889
+ if (!m) {
3890
+ m = document.createElement("div");
3891
+ m.id = "ais-silenced-modal";
3892
+ m.className = "tune-fork-backdrop";
3893
+ m.innerHTML =
3894
+ `<div class="modal-card">` +
3895
+ ` <h3 id="ais-silenced-title"></h3>` +
3896
+ ` <ul id="ais-silenced-list" style="list-style:none;padding:0;margin:10px 0;max-height:40vh;overflow-y:auto"></ul>` +
3897
+ ` <div class="tune-fork-actions">` +
3898
+ ` <button type="button" id="ais-silenced-clearall" class="secondary"></button>` +
3899
+ ` <button type="button" id="ais-silenced-close" class="ghost"></button>` +
3900
+ ` </div>` +
3901
+ `</div>`;
3902
+ document.body.appendChild(m);
3903
+ m.addEventListener("click", (ev) => { if (ev.target === m) m.hidden = true; });
3904
+ m.querySelector("#ais-silenced-close").addEventListener("click", () => { m.hidden = true; });
3905
+ m.querySelector("#ais-silenced-clearall").addEventListener("click", () => {
3906
+ state.aisLocalAckedMmsis.clear();
3907
+ m.hidden = true;
3908
+ try { renderAisAlarms(); } catch { /* silent */ }
3909
+ });
3910
+ }
3911
+ m.querySelector("#ais-silenced-title").textContent =
3912
+ (t("ais.silenced.title") || "Alarmas AIS silenciadas") + ` (${rows.length})`;
3913
+ m.querySelector("#ais-silenced-clearall").textContent =
3914
+ t("ais.silenced.clearAll") || "Reactivar todas";
3915
+ m.querySelector("#ais-silenced-close").textContent =
3916
+ t("ais.silenced.close") || "Cerrar";
3917
+ const ul = m.querySelector("#ais-silenced-list");
3918
+ ul.textContent = "";
3919
+ if (rows.length === 0) {
3920
+ const li = document.createElement("li");
3921
+ li.style.cssText = "padding:12px;color:var(--fg-dim);text-align:center";
3922
+ li.textContent = t("ais.silenced.empty") || "Ninguna alarma silenciada ahora mismo.";
3923
+ ul.appendChild(li);
3924
+ } else {
3925
+ for (const r of rows) {
3926
+ const li = document.createElement("li");
3927
+ li.style.cssText = "display:flex;align-items:center;gap:10px;padding:6px 4px;border-bottom:1px solid var(--border)";
3928
+ const span = document.createElement("span");
3929
+ span.style.cssText = "flex:1;font-family:ui-monospace,monospace;font-size:13px";
3930
+ span.textContent = `MMSI ${r.mmsi} · ${r.remainMin} min`;
3931
+ const btn = document.createElement("button");
3932
+ btn.type = "button";
3933
+ btn.className = "ghost";
3934
+ btn.style.cssText = "padding:4px 10px;font-size:12px";
3935
+ btn.textContent = t("ais.silenced.unsilence") || "Reactivar";
3936
+ btn.addEventListener("click", () => {
3937
+ state.aisLocalAckedMmsis.delete(r.mmsi);
3938
+ _aisShowSilencedModal(); // re-render in place
3939
+ });
3940
+ li.appendChild(span);
3941
+ li.appendChild(btn);
3942
+ ul.appendChild(li);
3943
+ }
3944
+ }
3945
+ m.hidden = false;
3946
+ m.classList.add("open");
3947
+ }
3948
+ async function _aisAcknowledge(id, mmsi) {
3949
+ // Rev436: silence THIS MMSI locally for 10 minutes, independent of
3950
+ // what UUID the next delta carries. Even if the id is empty (edge
3951
+ // case) we still silence the mmsi — the whole point of ACK is to
3952
+ // stop the specific vessel being pestered.
3953
+ try { state.aisLocalAckedMmsis.set(mmsi, Date.now() + AIS_ACK_TTL_MS); } catch { /* silent */ }
3954
+ try { state.aisAlarms.delete(mmsi); } catch { /* silent */ }
3955
+ try { renderAisAlarms(); } catch { /* silent */ }
3956
+ if (!id) return;
3957
+ try {
3958
+ const r = await skFetch(`/signalk/v2/api/notifications/${encodeURIComponent(id)}/acknowledge`, {
3959
+ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
3960
+ });
3961
+ if (!r.ok) console.warn("[ais] ACK HTTP", r.status, await r.text().catch(() => ""));
3962
+ } catch (e) { console.warn("[ais] ACK failed:", e); }
3963
+ }
3964
+
3486
3965
  // SVG compass rose. The card (cardinals + ticks) rotates so N points to
3487
3966
  // magnetic north regardless of boat heading; the boat is fixed pointing
3488
3967
  // up; TWO independent wind arrows (AWA amber solid, TWA sea-green solid)
@@ -3493,6 +3972,9 @@
3493
3972
  if (card && state.heading != null) {
3494
3973
  card.setAttribute("transform", `rotate(${-state.heading * RAD2DEG})`);
3495
3974
  }
3975
+ // Rev431: refresh AIS alarm bearings on every heading-driven redraw
3976
+ // so the red triangles track the boat rotation.
3977
+ try { renderAisAlarms(); } catch { /* silent */ }
3496
3978
 
3497
3979
  // Rev73: gota-chain redesign. IDs updated from rose-wind-arrow /
3498
3980
  // rose-wind-arrow-twa (line + arrowhead + text label) to
@@ -9070,6 +9552,80 @@
9070
9552
 
9071
9553
  // Rev21: calibration dropdown removed from Control tab. Access via
9072
9554
  // Setup > Calibration section instead.
9555
+
9556
+ // Rev433 (Carlos QA Rev432): Freeboard-SK EMBEDDED in the rose
9557
+ // box by default so the sailor keeps seeing the side panels
9558
+ // (mode, nudges, AP buttons). Double-tap on the rose toggles it
9559
+ // on/off; the ⤢ button promotes it to full-viewport, × hides it.
9560
+ // Iframe `src` is lazy on open and cleared on close so Freeboard
9561
+ // does not keep a websocket active while hidden.
9562
+ try {
9563
+ const rose = document.getElementById("wind-rose");
9564
+ const fbBox = document.getElementById("freeboard-embed");
9565
+ const fbIframe = document.getElementById("freeboard-embed-iframe");
9566
+ const fbClose = document.getElementById("freeboard-embed-close");
9567
+ const fbMax = document.getElementById("freeboard-embed-max");
9568
+ const openFb = () => {
9569
+ if (!fbBox || !fbIframe) return;
9570
+ if (!fbIframe.src || fbIframe.src === "about:blank") {
9571
+ fbIframe.src = "/@signalk/freeboard-sk/";
9572
+ }
9573
+ // Rev436 (Carlos QA Rev435): removed the _alSpeak on open —
9574
+ // TTS English voice turned "abriendo carta" / "AIS open chart"
9575
+ // into "ICE open chat" which was unintelligible. The visual
9576
+ // open is self-explanatory; no voice needed here.
9577
+ fbBox.hidden = false;
9578
+ };
9579
+ const closeFb = () => {
9580
+ if (!fbBox || !fbIframe) return;
9581
+ fbBox.hidden = true;
9582
+ fbBox.classList.remove("maximized");
9583
+ if (fbMax) fbMax.textContent = "⤢";
9584
+ fbIframe.src = "about:blank";
9585
+ };
9586
+ const toggleMax = () => {
9587
+ if (!fbBox) return;
9588
+ const now = fbBox.classList.toggle("maximized");
9589
+ if (fbMax) fbMax.textContent = now ? "⤡" : "⤢";
9590
+ if (fbMax) fbMax.setAttribute("title", now ? "Restore" : "Maximize");
9591
+ };
9592
+ if (rose) rose.addEventListener("dblclick", (ev) => {
9593
+ ev.preventDefault();
9594
+ if (fbBox && !fbBox.hidden) { closeFb(); } else { openFb(); }
9595
+ });
9596
+ if (fbClose) fbClose.addEventListener("click", closeFb);
9597
+ if (fbMax) fbMax.addEventListener("click", toggleMax);
9598
+ } catch (e) { console.warn("[rose] dblclick + freeboard wire:", e); }
9599
+
9600
+ // Rev432 (fix #1 ACK no funcionaba): the AIS infobox + ACK button
9601
+ // live in the HTML DOM, not the SVG, so we wire ONE persistent
9602
+ // click handler here that reads the current notification id from
9603
+ // the data attributes updated on each renderAisAlarms() tick.
9604
+ try {
9605
+ const ackBtn = document.getElementById("ais-infobox-ack");
9606
+ if (ackBtn) {
9607
+ ackBtn.addEventListener("click", (ev) => {
9608
+ ev.stopPropagation();
9609
+ const box = document.getElementById("ais-infobox");
9610
+ const id = box && box.dataset.notifId;
9611
+ const mmsi = box && box.dataset.mmsi;
9612
+ if (!id && !mmsi) { console.warn("[ais] ACK click but no notif id/mmsi in dataset"); return; }
9613
+ _aisAcknowledge(id, mmsi);
9614
+ });
9615
+ }
9616
+ // Rev438: prev/next between multiple active alarms.
9617
+ const prevBtn = document.getElementById("ais-infobox-prev");
9618
+ const nextBtn = document.getElementById("ais-infobox-next");
9619
+ const step = (dir) => {
9620
+ const n = state.aisAlarms ? state.aisAlarms.size : 0;
9621
+ if (n <= 1) return;
9622
+ if (typeof state._aisInfoboxIdx !== "number") state._aisInfoboxIdx = 0;
9623
+ state._aisInfoboxIdx = (state._aisInfoboxIdx + dir + n) % n;
9624
+ renderAisAlarms();
9625
+ };
9626
+ if (prevBtn) prevBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(-1); });
9627
+ if (nextBtn) nextBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(1); });
9628
+ } catch (e) { console.warn("[ais] ack wire:", e); }
9073
9629
  }
9074
9630
 
9075
9631
  // ---- Tune tab wiring ----
@@ -11361,6 +11917,36 @@
11361
11917
  _gustGhostTestFire();
11362
11918
  });
11363
11919
  }
11920
+ // Rev434 (Carlos QA Rev433): wire the AIS collision alarm overlay
11921
+ // card. Toggle on/off, toggle voice, and a shortcut to open the
11922
+ // collision-alerts plugin config in the SK admin UI.
11923
+ const aisCb = document.getElementById("dash-ais-alarm");
11924
+ if (aisCb) {
11925
+ aisCb.checked = _aisAlarmEnabled();
11926
+ aisCb.addEventListener("change", () => _aisAlarmSetEnabled(aisCb.checked));
11927
+ }
11928
+ const aisVoiceCb = document.getElementById("dash-ais-alarm-voice");
11929
+ if (aisVoiceCb) {
11930
+ aisVoiceCb.checked = _aisAlarmVoiceEnabled();
11931
+ aisVoiceCb.addEventListener("change", () => _aisAlarmSetVoiceEnabled(aisVoiceCb.checked));
11932
+ }
11933
+ const aisCfg = document.getElementById("dash-ais-alarm-config");
11934
+ if (aisCfg) {
11935
+ aisCfg.addEventListener("click", (ev) => {
11936
+ ev.preventDefault();
11937
+ ev.stopPropagation();
11938
+ window.open("/admin/#/serverConfiguration/plugins/signalk-collision-alerts", "_blank");
11939
+ });
11940
+ }
11941
+ // Rev438: open the "silenciados" modal.
11942
+ const aisSil = document.getElementById("dash-ais-alarm-silenced");
11943
+ if (aisSil) {
11944
+ aisSil.addEventListener("click", (ev) => {
11945
+ ev.preventDefault();
11946
+ ev.stopPropagation();
11947
+ _aisShowSilencedModal();
11948
+ });
11949
+ }
11364
11950
  const nb = $("#cfg-nudge-apply"); if (nb) nb.addEventListener("click", applyNudgeCfg);
11365
11951
  const langSel = $("#lang-select");
11366
11952
  if (langSel) {
package/public/index.html CHANGED
@@ -83,6 +83,45 @@
83
83
  <div class="dash-cell corner-tr" data-corner="tr"></div>
84
84
  <div class="dash-cell corner-bl" data-corner="bl"></div>
85
85
  <div class="dash-cell corner-br" data-corner="br"></div>
86
+ <!-- Rev432 (Carlos QA Rev431): AIS collision infobox + ACK as
87
+ an HTML absolute div (not SVG) so the ACK click is 100 %
88
+ reliable (no re-render-swallows-tap race), the box always
89
+ paints above every SVG layer, and the title shows WARN /
90
+ ALARM so the orange triangle meaning is unambiguous. -->
91
+ <div class="ais-infobox-html" id="ais-infobox" hidden>
92
+ <div class="ais-infobox-head">
93
+ <span class="ais-infobox-level" id="ais-infobox-level"></span>
94
+ <span class="ais-infobox-name" id="ais-infobox-name"></span>
95
+ <!-- Rev438: multi-target navigator. Hidden when there is
96
+ only one active alarm; "1/N" counter + prev/next
97
+ buttons otherwise. -->
98
+ <span class="ais-infobox-nav" id="ais-infobox-nav" hidden>
99
+ <button type="button" class="ais-infobox-nav-btn" id="ais-infobox-prev" aria-label="Previous">‹</button>
100
+ <span class="ais-infobox-nav-count" id="ais-infobox-nav-count">1/1</span>
101
+ <button type="button" class="ais-infobox-nav-btn" id="ais-infobox-next" aria-label="Next">›</button>
102
+ </span>
103
+ </div>
104
+ <div class="ais-infobox-body" id="ais-infobox-body"></div>
105
+ <!-- Rev438: closing speed row (range/tcpa derived); hidden
106
+ when tcpa is zero or negative (target already passed). -->
107
+ <div class="ais-infobox-sub" id="ais-infobox-closing" hidden></div>
108
+ <button type="button" class="ais-infobox-ack" id="ais-infobox-ack">ACK</button>
109
+ </div>
110
+ <!-- Rev433 (Carlos QA Rev432): Freeboard-SK EMBEDDED within
111
+ the rose box by default (double-tap toggles it on/off)
112
+ so the sailor keeps seeing the side panels (mode, nudges,
113
+ AP buttons). A maximize button ⤢ promotes it to the
114
+ full-viewport modal we had in Rev432, close button × to
115
+ hide it again. The iframe src is set lazily on open and
116
+ cleared on close so Freeboard does not keep a websocket
117
+ open while hidden. -->
118
+ <div class="freeboard-embed" id="freeboard-embed" hidden>
119
+ <div class="freeboard-embed-chrome">
120
+ <button type="button" class="freeboard-embed-btn" id="freeboard-embed-max" aria-label="Maximize" title="Maximize">⤢</button>
121
+ <button type="button" class="freeboard-embed-btn" id="freeboard-embed-close" aria-label="Close" title="Close">×</button>
122
+ </div>
123
+ <iframe id="freeboard-embed-iframe" title="Freeboard-SK"></iframe>
124
+ </div>
86
125
  <svg id="wind-rose" class="rose-svg" viewBox="-100 -110 200 220" preserveAspectRatio="xMidYMid meet">
87
126
  <!-- Outer compass ring -->
88
127
  <circle cx="0" cy="0" r="94" fill="none" stroke="#2b3542" stroke-width="2"/>
@@ -107,6 +146,14 @@
107
146
  <g id="rose-boat" transform="rotate(-90) scale(0.22)">
108
147
  <image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
109
148
  </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>
110
157
  <!-- Rev170/171 (Carlos): tack countdown overlay. Huge red
111
158
  digit over the boat, clickable to cancel. Two phases:
112
159
  phase "arming" → shows "5, 4, 3, 2, 1" (integer secs)
@@ -702,6 +749,38 @@
702
749
  data-i18n="dash.overlay.gust.test"
703
750
  onclick="event.preventDefault();event.stopPropagation();">Test now</button>
704
751
  </label>
752
+ <!-- Rev434 (Carlos QA Rev433): AIS collision overlay toggle.
753
+ Same overlay-card layout as the gust ghost. Default
754
+ enabled. Opens signalk-collision-alerts plugin config
755
+ in the SK admin UI from "Config" so Carlos can change
756
+ Harbour/Coastal/Offshore + per-field thresholds. -->
757
+ <label class="overlay-card">
758
+ <input type="checkbox" id="dash-ais-alarm" checked />
759
+ <svg class="overlay-preview" viewBox="-30 -60 60 60" preserveAspectRatio="xMidYMid meet">
760
+ <polygon points="0,-50 10,-20 -10,-20" fill="#ff3b30" stroke="#fff" stroke-width="1" stroke-linejoin="round"/>
761
+ <text x="0" y="-35" text-anchor="middle" dominant-baseline="middle"
762
+ fill="#fff" font-size="12" font-weight="900" font-family="ui-monospace, monospace">!</text>
763
+ </svg>
764
+ <div class="overlay-title" data-i18n="dash.overlay.ais.title">AIS collision alarm</div>
765
+ <div class="overlay-needs" data-i18n="dash.overlay.ais.needs">Needs signalk-collision-alerts plugin installed in your SK server. Reads notifications.navigation.closestApproach.* and renders a red/orange triangle on the rose with an infobox + ACK. Double-tap the rose to open Freeboard-SK embedded.</div>
766
+ <div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap">
767
+ <button type="button" id="dash-ais-alarm-config" class="ghost"
768
+ style="padding:6px 10px;font-size:12px"
769
+ data-i18n="dash.overlay.ais.config"
770
+ onclick="event.preventDefault();event.stopPropagation();">Open plugin config</button>
771
+ <!-- Rev438: open the "silenciados" modal so Carlos can
772
+ un-silence an MMSI before its 10-min TTL expires. -->
773
+ <button type="button" id="dash-ais-alarm-silenced" class="ghost"
774
+ style="padding:6px 10px;font-size:12px"
775
+ data-i18n="dash.overlay.ais.silenced"
776
+ onclick="event.preventDefault();event.stopPropagation();">Silenced…</button>
777
+ <label style="display:inline-flex;align-items:center;gap:6px;padding:4px 8px;font-size:12px;color:var(--fg-dim);cursor:pointer"
778
+ onclick="event.stopPropagation();">
779
+ <input type="checkbox" id="dash-ais-alarm-voice" checked />
780
+ <span data-i18n="dash.overlay.ais.voice">Voice alert</span>
781
+ </label>
782
+ </div>
783
+ </label>
705
784
  </div>
706
785
  </div>
707
786
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 10px" />
@@ -2322,6 +2401,6 @@
2322
2401
  </div>
2323
2402
  </div>
2324
2403
 
2325
- <script src="app.js?v=Rev430"></script>
2404
+ <script src="app.js?v=Rev438"></script>
2326
2405
  </body>
2327
2406
  </html>
package/public/style.css CHANGED
@@ -764,7 +764,11 @@ main {
764
764
  -webkit-user-select: none;
765
765
  }
766
766
  .overlay-card:hover { background: rgba(77, 212, 255, 0.06); }
767
- .overlay-card input[type=checkbox] {
767
+ /* Rev437 (Carlos QA Rev434): limit the "master" checkbox absolute
768
+ positioning to DIRECT children of the overlay-card. Nested
769
+ checkboxes (e.g. the AIS card's "Voice alert" sub-toggle) were
770
+ being hidden under the master in the top-right corner. */
771
+ .overlay-card > input[type=checkbox] {
768
772
  position: absolute;
769
773
  top: 6px;
770
774
  right: 6px;
@@ -4029,3 +4033,181 @@ body.pypilot-offline .pypilot-write {
4029
4033
  font-weight: 600;
4030
4034
  }
4031
4035
  .pm-dialog-actions button.primary:hover { background: #f0d04e; }
4036
+
4037
+ /* Rev431 (Carlos sea trial follow-up, feature #9): AIS collision
4038
+ overlay. Red triangles on the compass circle point inward from the
4039
+ bearing of each threat; the most-urgent one gets an infobox on top
4040
+ of the self boat with name + CPA + TCPA + ACK button. The
4041
+ notification source is signalk-collision-alerts (SK v2 Notifications
4042
+ 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
+ }
4055
+ @keyframes ais-blink {
4056
+ 0%, 100% { opacity: 1; }
4057
+ 50% { opacity: 0.35; }
4058
+ }
4059
+ /* Rev432: AIS infobox + ACK as HTML absolute div above the SVG rose.
4060
+ Guarantees a reliable click target (no SVG re-render race) and sits
4061
+ on top of every SVG layer regardless of DOM order inside the SVG. */
4062
+ .rose-wrap { position: relative; } /* anchor for infobox + freeboard */
4063
+ .ais-infobox-html {
4064
+ position: absolute;
4065
+ top: 10%;
4066
+ left: 50%;
4067
+ transform: translateX(-50%);
4068
+ z-index: 20;
4069
+ background: rgba(10, 14, 20, 0.96);
4070
+ border: 2px solid #ff3b30;
4071
+ border-radius: 8px;
4072
+ padding: 8px 10px;
4073
+ display: flex;
4074
+ flex-direction: column;
4075
+ gap: 4px;
4076
+ min-width: 180px;
4077
+ max-width: 85%;
4078
+ color: #fff;
4079
+ font-family: ui-monospace, monospace;
4080
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
4081
+ pointer-events: auto;
4082
+ }
4083
+ .ais-infobox-html[hidden] { display: none; }
4084
+ .ais-infobox-html.ais-warn { border-color: #f5a623; }
4085
+ .ais-infobox-html.ais-alarm { border-color: #ff3b30; animation: ais-blink 900ms infinite; }
4086
+ .ais-infobox-head {
4087
+ display: flex;
4088
+ align-items: center;
4089
+ gap: 8px;
4090
+ }
4091
+ .ais-infobox-level {
4092
+ font-size: 10px;
4093
+ font-weight: 900;
4094
+ letter-spacing: 1px;
4095
+ padding: 2px 6px;
4096
+ border-radius: 3px;
4097
+ background: #ff3b30;
4098
+ color: #fff;
4099
+ }
4100
+ .ais-infobox-html.ais-warn .ais-infobox-level { background: #f5a623; color: #1a1d22; }
4101
+ .ais-infobox-name {
4102
+ font-size: 14px;
4103
+ font-weight: 700;
4104
+ color: #fff;
4105
+ flex: 1;
4106
+ overflow: hidden;
4107
+ text-overflow: ellipsis;
4108
+ white-space: nowrap;
4109
+ }
4110
+ .ais-infobox-body {
4111
+ font-size: 12px;
4112
+ font-weight: 500;
4113
+ color: #cbd3dc;
4114
+ }
4115
+ .ais-infobox-ack {
4116
+ align-self: flex-end;
4117
+ background: #ff3b30;
4118
+ color: #fff;
4119
+ border: 1px solid #fff;
4120
+ border-radius: 4px;
4121
+ padding: 6px 14px;
4122
+ font-size: 12px;
4123
+ font-weight: 900;
4124
+ cursor: pointer;
4125
+ min-width: 60px;
4126
+ min-height: 32px;
4127
+ }
4128
+ .ais-infobox-html.ais-warn .ais-infobox-ack { background: #f5a623; color: #1a1d22; }
4129
+ .ais-infobox-ack:hover { filter: brightness(1.1); }
4130
+ .ais-infobox-ack:active { transform: scale(0.95); }
4131
+ /* Rev438: multi-target navigator inside the head row. Compact buttons
4132
+ + fraction counter. Hidden by default when there's only one alarm. */
4133
+ .ais-infobox-nav {
4134
+ display: inline-flex;
4135
+ align-items: center;
4136
+ gap: 4px;
4137
+ }
4138
+ .ais-infobox-nav[hidden] { display: none; }
4139
+ .ais-infobox-nav-btn {
4140
+ background: rgba(255,255,255,0.1);
4141
+ color: #fff;
4142
+ border: 1px solid rgba(255,255,255,0.3);
4143
+ border-radius: 3px;
4144
+ padding: 2px 7px;
4145
+ font-size: 14px;
4146
+ font-weight: 700;
4147
+ cursor: pointer;
4148
+ line-height: 1;
4149
+ }
4150
+ .ais-infobox-nav-btn:hover { background: rgba(255,255,255,0.2); }
4151
+ .ais-infobox-nav-count {
4152
+ font-size: 11px;
4153
+ font-weight: 700;
4154
+ color: #fff;
4155
+ min-width: 28px;
4156
+ text-align: center;
4157
+ }
4158
+ .ais-infobox-sub {
4159
+ font-size: 11px;
4160
+ color: #cbd3dc;
4161
+ }
4162
+ .ais-infobox-sub[hidden] { display: none; }
4163
+
4164
+ /* Rev433 (Carlos QA Rev432): Freeboard-SK embedded within the rose
4165
+ box so the sailor keeps seeing the side panels. Default is embedded
4166
+ (position:absolute inside .rose-wrap); .maximized promotes it to
4167
+ full-viewport (position:fixed) as a modal would. Toggle via the ⤢
4168
+ button next to the close ×. */
4169
+ .freeboard-embed {
4170
+ position: absolute;
4171
+ inset: 0;
4172
+ z-index: 25;
4173
+ background: var(--bg-2, #0f1620);
4174
+ border-radius: 10px;
4175
+ overflow: hidden;
4176
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
4177
+ }
4178
+ .freeboard-embed[hidden] { display: none; }
4179
+ .freeboard-embed.maximized {
4180
+ position: fixed;
4181
+ inset: 2vh 2vw;
4182
+ z-index: 1300;
4183
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.7);
4184
+ }
4185
+ .freeboard-embed iframe {
4186
+ width: 100%;
4187
+ height: 100%;
4188
+ border: 0;
4189
+ display: block;
4190
+ }
4191
+ .freeboard-embed-chrome {
4192
+ position: absolute;
4193
+ top: 6px;
4194
+ right: 10px;
4195
+ z-index: 2;
4196
+ display: flex;
4197
+ gap: 6px;
4198
+ }
4199
+ .freeboard-embed-btn {
4200
+ background: rgba(0, 0, 0, 0.65);
4201
+ color: #fff;
4202
+ border: 1px solid #fff;
4203
+ border-radius: 4px;
4204
+ font-size: 18px;
4205
+ width: 32px;
4206
+ height: 32px;
4207
+ line-height: 1;
4208
+ cursor: pointer;
4209
+ display: flex;
4210
+ align-items: center;
4211
+ justify-content: center;
4212
+ }
4213
+ .freeboard-embed-btn:hover { background: rgba(0, 0, 0, 0.85); }