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 +107 -0
- package/README.md +31 -0
- package/dist/index.js +1 -1
- package/package.json +9 -2
- package/public/app.js +586 -0
- package/public/index.html +80 -1
- package/public/style.css +183 -1
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 = "
|
|
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": "
|
|
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=
|
|
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
|
-
|
|
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); }
|