signalk-underkeel 0.6.0 → 0.6.2
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 +28 -0
- package/README.md +9 -1
- package/lib/api/manifest.js +1 -0
- package/package.json +1 -1
- package/public/panel.html +15 -7
- package/public/panel.js +49 -16
- package/public/runtime.js +54 -1
- package/public/setup.html +6 -15
- package/public/setup.js +3 -7
- package/public/theme.css +59 -0
- package/public/theme.js +75 -0
- package/public/ukc-shared.js +17 -9
- package/public/widget.html +9 -3
- package/public/widget.js +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to signalk-underkeel are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.6.2] - 2026-11-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Closing the details panel no longer logs an error in the Signal K server log. The panel
|
|
10
|
+
subscribed to `notifications.navigation.underKeel` itself, and on close Freeboard sent
|
|
11
|
+
Signal K an unsubscribe for that one path, which Signal K refuses (it takes only an
|
|
12
|
+
unsubscribe of everything). The background page, which never closes, now holds that
|
|
13
|
+
subscription and passes the message to the panel.
|
|
14
|
+
|
|
15
|
+
## [0.6.1] - 2026-11-13
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- The details panel reads in dark mode (#67): it had fixed light-theme colours and no
|
|
20
|
+
background of its own, so on Freeboard's dark background most of its text was dark grey on
|
|
21
|
+
dark. The status chip and widget also put white on amber (Alert, 2.4:1) and on green
|
|
22
|
+
(Normal, 3.7:1), below the 4.5:1 that small text needs.
|
|
23
|
+
- The SignalK plugin CI workflow is pinned to a commit again. Without the `@ref`, GitHub could
|
|
24
|
+
not read the workflow file, so no plugin CI ran for 0.6.0 and the plugin registry took 10
|
|
25
|
+
points from its score.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- The widget, the details panel and the setup steps share one set of colours in three
|
|
30
|
+
schemes: day, dusk (the device's dark mode) and night (Freeboard's night mode: black, and
|
|
31
|
+
nothing brighter than a dim grey). Every text colour reads at WCAG AA in each scheme.
|
|
32
|
+
|
|
5
33
|
## [0.6.0] - 2026-11-10
|
|
6
34
|
|
|
7
35
|
### Changed
|
package/README.md
CHANGED
|
@@ -275,6 +275,11 @@ Extensions API shows it without any change to Freeboard:
|
|
|
275
275
|
| Widget grid | Under-keel status: state colour, minimum clearance, time to shoal and the reason |
|
|
276
276
|
| Toolbar button (boat icon) | details panel: state, water level and source, a leg table per route, and **Open setup** for boat, water level and warning style (Settings) |
|
|
277
277
|
|
|
278
|
+
The widget, the panel and the setup steps follow Freeboard's look: light by day, dark when the
|
|
279
|
+
device is in dark mode, and a dim night scheme, black with nothing brighter than a dim grey,
|
|
280
|
+
while Freeboard's night mode is on, so a glance at the panel keeps the eyes adapted to the
|
|
281
|
+
dark. Every text colour reads at WCAG AA in each scheme.
|
|
282
|
+
|
|
278
283
|
Routes shown on the chart are checked 1 s after each edit and every minute; the panel shows
|
|
279
284
|
each result's age and marks it "out of date" after 3 minutes, when the chart draws its legs
|
|
280
285
|
`unverified`. Stale status is shown as Unknown,
|
|
@@ -502,7 +507,10 @@ panel with its toolbar button, a hidden background page and the experimental
|
|
|
502
507
|
- `runtime.js`, the background page, keeps the envelope, route results and tiles current;
|
|
503
508
|
- `widget.js` and `panel.js` render the status and the route legs;
|
|
504
509
|
- `ukc-shared.js` holds their pure logic (freshness, badges, labels), the part the Jest tests
|
|
505
|
-
cover
|
|
510
|
+
cover;
|
|
511
|
+
- `theme.css` holds every colour of the pages in the day, dusk and night schemes, and
|
|
512
|
+
`theme.js` picks the scheme from Freeboard's night mode (`nightMode`) or the device's dark
|
|
513
|
+
mode; a test holds each text colour to WCAG AA contrast in every scheme.
|
|
506
514
|
|
|
507
515
|
The pages talk to the host through the vendored `signalk-plotterext-bus` client, copied into
|
|
508
516
|
`public/vendor/` by the build. The [Plotter Extensions services RFC](docs/rfcs/plotter-extensions-services.md)
|
package/lib/api/manifest.js
CHANGED
package/package.json
CHANGED
package/public/panel.html
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title>Under-keel details</title>
|
|
6
|
+
<link rel="stylesheet" href="theme.css" />
|
|
6
7
|
<style>
|
|
7
|
-
body { margin: 0; padding: 10px 12px; font: 14px/1.4 system-ui, sans-serif; }
|
|
8
|
+
body { margin: 0; padding: 10px 12px; font: 14px/1.4 system-ui, sans-serif; color: var(--ink); background: var(--page); }
|
|
8
9
|
h2 { font-size: 15px; margin: 14px 0 6px; }
|
|
9
|
-
.advisory { font-size: 12px; padding: 6px 8px; background:
|
|
10
|
-
#state { display: inline-block; padding: 2px 8px; border-radius: 4px; color:
|
|
10
|
+
.advisory { font-size: 12px; padding: 6px 8px; border-radius: 4px; color: var(--note-ink); background: var(--note); border: 1px solid var(--note-line); }
|
|
11
|
+
#state { display: inline-block; padding: 2px 8px; border-radius: 4px; font-weight: 700; color: var(--on-st-unknown); background: var(--st-unknown); }
|
|
12
|
+
#state[data-state="normal"] { color: var(--on-st-normal); background: var(--st-normal); }
|
|
13
|
+
#state[data-state="alert"] { color: var(--on-st-alert); background: var(--st-alert); }
|
|
14
|
+
#state[data-state="warn"] { color: var(--on-st-warn); background: var(--st-warn); }
|
|
15
|
+
#state[data-state="alarm"] { color: var(--on-st-alarm); background: var(--st-alarm); }
|
|
11
16
|
#message { margin-top: 6px; }
|
|
12
17
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
13
|
-
td, th { text-align: left; padding: 3px 4px; border-bottom: 1px solid
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
td, th { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--line); }
|
|
19
|
+
th { color: var(--muted); font-weight: 600; }
|
|
20
|
+
.ok { color: var(--ok); } .marginal { color: var(--warn); } .fail { color: var(--todo); } .unverified { color: var(--unverified); }
|
|
21
|
+
button { margin-top: 8px; min-height: 32px; padding: 0 12px; font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--field); border-radius: 6px; cursor: pointer; }
|
|
22
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
23
|
+
.muted { color: var(--muted); font-size: 12px; }
|
|
17
24
|
</style>
|
|
25
|
+
<script src="theme.js"></script>
|
|
18
26
|
<script src="ukc-shared.js"></script>
|
|
19
27
|
<script type="module" src="panel.js"></script>
|
|
20
28
|
</head>
|
package/public/panel.js
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
import { connectExtension } from "./vendor/plotterext-bus/extension.js";
|
|
4
4
|
|
|
5
5
|
const UKC = globalThis.UKC;
|
|
6
|
+
const THEME = globalThis.UKC_THEME;
|
|
7
|
+
/** The host's night mode as this page shows it; setup opens in the same scheme. */
|
|
8
|
+
let scheme = null;
|
|
6
9
|
// Setup opens in this panel rather than one of its own, so the toolbar keeps one button.
|
|
7
10
|
document.getElementById("open-setup").addEventListener("click", () => {
|
|
8
|
-
location.assign(
|
|
11
|
+
location.assign(`setup.html?from=details${scheme ? `&scheme=${scheme}` : ""}`);
|
|
9
12
|
});
|
|
10
13
|
const client = await connectExtension();
|
|
14
|
+
// Not awaited: a host that offers night mode but never answers must not hold back the status.
|
|
15
|
+
void THEME.followHost(client, document, (s) => (scheme = s));
|
|
11
16
|
let unit = "m";
|
|
12
17
|
|
|
13
18
|
if (client.hasCapability("units")) {
|
|
@@ -19,11 +24,15 @@ function text(id, value) {
|
|
|
19
24
|
document.getElementById(id).textContent = value;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
/** The state chip: its label, and its colours from theme.css for the state. */
|
|
28
|
+
function showState(state) {
|
|
24
29
|
const chip = document.getElementById("state");
|
|
25
|
-
chip.textContent =
|
|
26
|
-
chip.
|
|
30
|
+
chip.textContent = UKC.stateStyle(state).label;
|
|
31
|
+
chip.dataset.state = UKC.stateKey(state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function showUnknownStatus(why) {
|
|
35
|
+
showState("unknown");
|
|
27
36
|
text("source", `Under-keel status unavailable: ${why}`);
|
|
28
37
|
}
|
|
29
38
|
|
|
@@ -53,10 +62,7 @@ async function refreshStatus() {
|
|
|
53
62
|
return;
|
|
54
63
|
}
|
|
55
64
|
statusAt = Date.now();
|
|
56
|
-
|
|
57
|
-
const chip = document.getElementById("state");
|
|
58
|
-
chip.textContent = style.label;
|
|
59
|
-
chip.style.background = style.colour;
|
|
65
|
+
showState(s.engine.state);
|
|
60
66
|
text("source", UKC.statusLines(s, unit).join(" · "));
|
|
61
67
|
} catch (err) {
|
|
62
68
|
if (id !== statusRequest) {
|
|
@@ -139,19 +145,45 @@ async function refreshRoutes() {
|
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
/** Reads overlap as the status ones do: only the latest may update the message. */
|
|
149
|
+
let noticeRequest = 0;
|
|
150
|
+
|
|
151
|
+
/** The notification's message, which the background page keeps in the extension state. */
|
|
152
|
+
async function refreshNotice() {
|
|
153
|
+
const id = ++noticeRequest;
|
|
154
|
+
try {
|
|
155
|
+
const { ukcNotice } = await client.state.get(["ukcNotice"], "extension");
|
|
156
|
+
if (id !== noticeRequest) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
text("message", ukcNotice && typeof ukcNotice.message === "string" ? ukcNotice.message : "");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (id !== noticeRequest) {
|
|
162
|
+
return;
|
|
147
163
|
}
|
|
148
|
-
|
|
164
|
+
// An old message left on screen could say normal over a warning; the poll reads it again.
|
|
165
|
+
text("message", "Under-keel message unavailable: the chart host did not answer.");
|
|
166
|
+
console.warn("under-keel notice", err);
|
|
167
|
+
}
|
|
149
168
|
}
|
|
169
|
+
|
|
170
|
+
// No Signal K subscription of its own: the host would unsubscribe it path by path when the
|
|
171
|
+
// panel closes, which Signal K refuses and logs as an error. The background page relays it.
|
|
150
172
|
await client.subscribe(["state.changed"], (_name, params) => {
|
|
151
|
-
if (params
|
|
173
|
+
if (!params || params.scope !== "extension") {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (params.keys.includes("routeCheck")) {
|
|
152
177
|
void refreshRoutes();
|
|
153
178
|
}
|
|
179
|
+
if (params.keys.includes("ukcNotice")) {
|
|
180
|
+
void refreshNotice();
|
|
181
|
+
if (!document.hidden) {
|
|
182
|
+
void refreshStatus();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
154
185
|
});
|
|
186
|
+
void refreshNotice();
|
|
155
187
|
// The background runtime answers the button, over an event the host must carry.
|
|
156
188
|
const recheck = document.getElementById("recheck");
|
|
157
189
|
recheck.hidden = !["events.publish", "routes", "background.iframe"].every((c) => client.hasCapability(c));
|
|
@@ -170,6 +202,7 @@ function checkStatusAge() {
|
|
|
170
202
|
function poll() {
|
|
171
203
|
void refreshStatus();
|
|
172
204
|
void refreshRoutes();
|
|
205
|
+
void refreshNotice();
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
// A hidden page polls nothing; back on screen an old status reads unknown until the poll answers.
|
package/public/runtime.js
CHANGED
|
@@ -427,11 +427,64 @@ if (has("routes")) {
|
|
|
427
427
|
}, RECHECK_MS);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
// ---- the notification, relayed to the details panel ----
|
|
431
|
+
/**
|
|
432
|
+
* The panel opens and closes, and the chart host unsubscribes a closed page's Signal K paths
|
|
433
|
+
* one by one, which Signal K refuses: it only takes an unsubscribe of everything, and logs the
|
|
434
|
+
* rest as errors. So this page, which never closes, holds the notification's subscription and
|
|
435
|
+
* keeps its message in the extension state, where the panel reads it.
|
|
436
|
+
*/
|
|
437
|
+
const NOTICE = "notifications.navigation.underKeel";
|
|
438
|
+
/** A write that fails is tried again this many times, each wait longer by this much. */
|
|
439
|
+
const NOTICE_RETRIES = 5;
|
|
440
|
+
const NOTICE_RETRY_MS = 2000;
|
|
441
|
+
/** The last notice received and not given up on, as JSON; a newer one replaces a retry of an older one. */
|
|
442
|
+
let latestNotice;
|
|
443
|
+
let noticeRetry;
|
|
444
|
+
|
|
445
|
+
function relayNotice(value) {
|
|
446
|
+
const n = value && typeof value === "object" ? value : {};
|
|
447
|
+
const notice = { message: typeof n.message === "string" ? n.message : "", state: n.state };
|
|
448
|
+
const key = JSON.stringify(notice);
|
|
449
|
+
if (key === latestNotice) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
latestNotice = key;
|
|
453
|
+
clearTimeout(noticeRetry);
|
|
454
|
+
void writeNotice(notice, key, 0);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Writes the notice to the extension state. A notification is sent only when it changes, so
|
|
459
|
+
* a write that fails is tried again rather than left until the next one.
|
|
460
|
+
*/
|
|
461
|
+
async function writeNotice(notice, key, tries) {
|
|
462
|
+
try {
|
|
463
|
+
await client.state.set({ ukcNotice: notice }, "extension");
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (key !== latestNotice) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (tries < NOTICE_RETRIES) {
|
|
469
|
+
noticeRetry = setTimeout(() => void writeNotice(notice, key, tries + 1), NOTICE_RETRY_MS * (tries + 1));
|
|
470
|
+
} else {
|
|
471
|
+
// Given up: the same notice, sent again on a reconnect, is written rather than dropped.
|
|
472
|
+
latestNotice = undefined;
|
|
473
|
+
console.warn("under-keel notice", err);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
430
478
|
/** Without the Signal K stream, or when subscribing to it fails, the envelope is polled. */
|
|
431
479
|
let streaming = false;
|
|
432
480
|
if (has("signalk.stream")) {
|
|
433
481
|
try {
|
|
434
|
-
await client.signalk.subscribe(Object.keys(onPath), (ev) => {
|
|
482
|
+
await client.signalk.subscribe([...Object.keys(onPath), NOTICE], (ev) => {
|
|
483
|
+
// A notification is sent only when it changes, so its age says nothing about the plugin.
|
|
484
|
+
if (ev.path === NOTICE) {
|
|
485
|
+
relayNotice(ev.value);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
435
488
|
const left = fresh(ev.path, ev.timestamp, Date.now());
|
|
436
489
|
if (left > 0) {
|
|
437
490
|
onPath[ev.path](ev.value, left);
|
package/public/setup.html
CHANGED
|
@@ -4,21 +4,8 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Under-keel setup</title>
|
|
7
|
+
<link rel="stylesheet" href="theme.css" />
|
|
7
8
|
<style>
|
|
8
|
-
:root {
|
|
9
|
-
--ink: #12233a; --muted: #4a5b70; --line: #d5dce5; --field: #b8c3d1; --accent: #1f5fa8;
|
|
10
|
-
--accent-soft: #eaf2fb; --page: #ffffff; --panel: #f6f8fb; --water: #dcebf7; --bed: #b9a98f;
|
|
11
|
-
--ok: #1a7340; --warn: #8a4b00; --todo: #b5261a; --margin: #e66101; --margin-ink: #a84300;
|
|
12
|
-
--note: #fff4e5; --note-line: #f0c27a; --note-ink: #5c3300; --on-accent: #ffffff;
|
|
13
|
-
}
|
|
14
|
-
@media (prefers-color-scheme: dark) {
|
|
15
|
-
:root {
|
|
16
|
-
--ink: #e6edf5; --muted: #a9b6c6; --line: #33414f; --field: #4b5b6c; --accent: #6ea8ef;
|
|
17
|
-
--accent-soft: #1b2c42; --page: #121a23; --panel: #1a2430; --water: #173049; --bed: #5d5243;
|
|
18
|
-
--ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --margin: #ff9d4d; --margin-ink: #ffb27a;
|
|
19
|
-
--note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8; --on-accent: #0b1520;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
9
|
* { box-sizing: border-box; }
|
|
23
10
|
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; color: var(--ink); background: var(--page); }
|
|
24
11
|
.back { padding: 10px 16px 0; font-size: 14px; }
|
|
@@ -64,7 +51,10 @@
|
|
|
64
51
|
.seg button[aria-checked="true"] { background: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
65
52
|
.levels { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
|
66
53
|
.levels div { display: flex; gap: 10px; align-items: center; }
|
|
67
|
-
.chip { width: 60px; flex: none; text-align: center; border-radius: 4px; font-size: 12px; font-weight: 600; padding: 3px 0;
|
|
54
|
+
.chip { width: 60px; flex: none; text-align: center; border-radius: 4px; font-size: 12px; font-weight: 600; padding: 3px 0; }
|
|
55
|
+
.chip.alert { color: var(--on-st-alert); background: var(--st-alert); }
|
|
56
|
+
.chip.warn { color: var(--on-st-warn); background: var(--st-warn); }
|
|
57
|
+
.chip.alarm { color: var(--on-st-alarm); background: var(--st-alarm); }
|
|
68
58
|
.switches { border: 1px solid var(--line); border-radius: 8px; margin-top: 6px; }
|
|
69
59
|
.switches label { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; padding: 0 12px; border-bottom: 1px solid var(--line); }
|
|
70
60
|
.switches label:last-child { border-bottom: 0; }
|
|
@@ -83,6 +73,7 @@
|
|
|
83
73
|
a { color: var(--accent); }
|
|
84
74
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
85
75
|
</style>
|
|
76
|
+
<script src="theme.js"></script>
|
|
86
77
|
<script src="setup-model.js"></script>
|
|
87
78
|
<script type="module" src="setup.js"></script>
|
|
88
79
|
</head>
|
package/public/setup.js
CHANGED
|
@@ -4,12 +4,6 @@ const M = globalThis.UKC_SETUP;
|
|
|
4
4
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
5
5
|
const ADMIN_PAGE = "/admin/#/serverConfiguration/plugins/signalk-underkeel";
|
|
6
6
|
const STEPS = ["Boat", "Water level", "Warnings"];
|
|
7
|
-
/** The plugin's state colours, each with a text colour that reads on it. */
|
|
8
|
-
const STATE_COLOURS = {
|
|
9
|
-
alert: ["#d4a000", "#12233a"],
|
|
10
|
-
warn: ["#e66101", "#12233a"],
|
|
11
|
-
alarm: ["#b5261a", "#ffffff"]
|
|
12
|
-
};
|
|
13
7
|
const REFERENCE_KNOTS = 6;
|
|
14
8
|
|
|
15
9
|
const s = { data: null, patch: {}, step: 0, saving: false, dragScale: undefined };
|
|
@@ -493,7 +487,7 @@ function warningsStep() {
|
|
|
493
487
|
"div",
|
|
494
488
|
{ class: "levels" },
|
|
495
489
|
["alert", "warn", "alarm"].map((k) =>
|
|
496
|
-
h("div", {}, h("span", { class:
|
|
490
|
+
h("div", {}, h("span", { class: `chip ${k}` }, k[0].toUpperCase() + k.slice(1)), lines[k])
|
|
497
491
|
),
|
|
498
492
|
h("span", { class: "hint" }, lines.ahead),
|
|
499
493
|
custom.length > 0 ? h("span", { class: "hint" }, `Your own values for ${custom.join(", ")} still apply; change them in the admin page.`) : null
|
|
@@ -646,4 +640,6 @@ async function readBack(sent, tries = 5) {
|
|
|
646
640
|
|
|
647
641
|
// Opened from the details panel, the page offers the way back to it.
|
|
648
642
|
document.getElementById("back").hidden = new URLSearchParams(location.search).get("from") !== "details";
|
|
643
|
+
// Opened from the details panel, setup shows the scheme the panel had from the host's night mode.
|
|
644
|
+
globalThis.UKC_THEME.applyScheme(document, globalThis.UKC_THEME.schemeFromUrl(location.href));
|
|
649
645
|
await load();
|
package/public/theme.css
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Colours of the extension pages, one set per scheme. The pages use these tokens only, so a
|
|
3
|
+
* colour is changed here once; __tests__/ext/theme.test.ts holds every text colour to WCAG
|
|
4
|
+
* contrast against the surfaces it is drawn on, in every scheme.
|
|
5
|
+
*
|
|
6
|
+
* day: light, the default.
|
|
7
|
+
* dusk: dark, when the OS prefers it (prefers-color-scheme) or the page is told to.
|
|
8
|
+
* night: the chart host's night mode (`nightMode`): black, and nothing brighter than a dim
|
|
9
|
+
* grey, so the helmsman's eyes stay adapted to the dark (theme.js sets it).
|
|
10
|
+
*/
|
|
11
|
+
:root {
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
--page: #ffffff; --panel: #f6f8fb; --ink: #12233a; --muted: #4a5b70; --line: #d5dce5;
|
|
14
|
+
--field: #b8c3d1; --accent: #1f5fa8; --accent-soft: #eaf2fb; --on-accent: #ffffff;
|
|
15
|
+
--water: #dcebf7; --bed: #b9a98f; --margin: #e66101; --margin-ink: #a84300;
|
|
16
|
+
--ok: #1a7340; --warn: #8a4b00; --todo: #b5261a; --unverified: #5b3f9e;
|
|
17
|
+
--note: #fff4e5; --note-line: #f0c27a; --note-ink: #5c3300;
|
|
18
|
+
--st-normal: #1a7340; --on-st-normal: #ffffff; --st-alert: #d4a000; --on-st-alert: #12233a;
|
|
19
|
+
--st-warn: #e66101; --on-st-warn: #12233a; --st-alarm: #b5261a; --on-st-alarm: #ffffff;
|
|
20
|
+
--st-unknown: #5f6b78; --on-st-unknown: #ffffff;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root:not([data-scheme]) {
|
|
24
|
+
color-scheme: dark;
|
|
25
|
+
--page: #121a23; --panel: #1a2430; --ink: #e6edf5; --muted: #a9b6c6; --line: #33414f;
|
|
26
|
+
--field: #4b5b6c; --accent: #6ea8ef; --accent-soft: #1b2c42; --on-accent: #0b1520;
|
|
27
|
+
--water: #173049; --bed: #5d5243; --margin: #ff9d4d; --margin-ink: #ffb27a;
|
|
28
|
+
--ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --unverified: #c3b0f7;
|
|
29
|
+
--note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8;
|
|
30
|
+
--st-normal: #6fcf97; --on-st-normal: #0b1520; --st-alert: #f2c94c; --on-st-alert: #0b1520;
|
|
31
|
+
--st-warn: #ff9d4d; --on-st-warn: #0b1520; --st-alarm: #ff6f61; --on-st-alarm: #0b1520;
|
|
32
|
+
--st-unknown: #8a96a3; --on-st-unknown: #0b1520;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
:root[data-scheme="dusk"] {
|
|
36
|
+
color-scheme: dark;
|
|
37
|
+
--page: #121a23; --panel: #1a2430; --ink: #e6edf5; --muted: #a9b6c6; --line: #33414f;
|
|
38
|
+
--field: #4b5b6c; --accent: #6ea8ef; --accent-soft: #1b2c42; --on-accent: #0b1520;
|
|
39
|
+
--water: #173049; --bed: #5d5243; --margin: #ff9d4d; --margin-ink: #ffb27a;
|
|
40
|
+
--ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --unverified: #c3b0f7;
|
|
41
|
+
--note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8;
|
|
42
|
+
--st-normal: #6fcf97; --on-st-normal: #0b1520; --st-alert: #f2c94c; --on-st-alert: #0b1520;
|
|
43
|
+
--st-warn: #ff9d4d; --on-st-warn: #0b1520; --st-alarm: #ff6f61; --on-st-alarm: #0b1520;
|
|
44
|
+
--st-unknown: #8a96a3; --on-st-unknown: #0b1520;
|
|
45
|
+
}
|
|
46
|
+
:root[data-scheme="night"] {
|
|
47
|
+
color-scheme: dark;
|
|
48
|
+
--page: #000000; --panel: #0d0807; --ink: #b8786c; --muted: #a86f63; --line: #2a1814;
|
|
49
|
+
--field: #3a221d; --accent: #b9805a; --accent-soft: #1a0e08; --on-accent: #000000;
|
|
50
|
+
--water: #0a1014; --bed: #1c140e; --margin: #b9805a; --margin-ink: #b9805a;
|
|
51
|
+
--ok: #6f9160; --warn: #b08a45; --todo: #e0675a; --unverified: #9c809b;
|
|
52
|
+
--note: #1a0e08; --note-line: #3a221d; --note-ink: #b8786c;
|
|
53
|
+
--st-normal: #1d2a14; --on-st-normal: #7c9a69; --st-alert: #2b220c; --on-st-alert: #ad8e47;
|
|
54
|
+
--st-warn: #2e1909; --on-st-warn: #c07f4c; --st-alarm: #3a0c08; --on-st-alarm: #e66e61;
|
|
55
|
+
--st-unknown: #1c1716; --on-st-unknown: #9a817b;
|
|
56
|
+
}
|
|
57
|
+
:root[data-scheme="day"] {
|
|
58
|
+
color-scheme: light;
|
|
59
|
+
}
|
package/public/theme.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which colour scheme of theme.css a page shows. Plain script: the pages load it with
|
|
2
|
+
// <script>, the tests require() it.
|
|
3
|
+
//
|
|
4
|
+
// The chart host's night mode wins: on, the page is "night"; off, or not offered, the page
|
|
5
|
+
// follows the OS preference (day, or dusk when it prefers dark), with no data-scheme set.
|
|
6
|
+
(function (root) {
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const SCHEMES = ["day", "dusk", "night"];
|
|
10
|
+
|
|
11
|
+
/** Shows `scheme` on `doc`; null or an unknown name follows the OS preference. */
|
|
12
|
+
function applyScheme(doc, scheme) {
|
|
13
|
+
const el = doc.documentElement;
|
|
14
|
+
if (SCHEMES.includes(scheme)) {
|
|
15
|
+
el.setAttribute("data-scheme", scheme);
|
|
16
|
+
} else {
|
|
17
|
+
el.removeAttribute("data-scheme");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The scheme a host night-mode state asks for: night when enabled, else none (the OS's). */
|
|
22
|
+
function schemeOf(state) {
|
|
23
|
+
return state && state.enabled === true ? "night" : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The scheme named in a page URL's `scheme` parameter, or null. */
|
|
27
|
+
function schemeFromUrl(href) {
|
|
28
|
+
try {
|
|
29
|
+
const s = new URL(href).searchParams.get("scheme");
|
|
30
|
+
return SCHEMES.includes(s) ? s : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Follows the host's night mode on `doc` while the page lives, when the host offers it
|
|
38
|
+
* (capability `nightMode`). Resolves with the scheme shown; a host that does not answer
|
|
39
|
+
* leaves the page following the OS.
|
|
40
|
+
*/
|
|
41
|
+
async function followHost(client, doc, onChange) {
|
|
42
|
+
if (!client.hasCapability("nightMode")) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const show = (state) => {
|
|
46
|
+
const scheme = schemeOf(state);
|
|
47
|
+
applyScheme(doc, scheme);
|
|
48
|
+
if (onChange) {
|
|
49
|
+
onChange(scheme);
|
|
50
|
+
}
|
|
51
|
+
return scheme;
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
// Subscribed first, so a change while the state is read is not missed.
|
|
55
|
+
await client.subscribe(["nightMode.changed"], (_name, params) => show(params));
|
|
56
|
+
return show(await client.call("nightMode.get"));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn("under-keel night mode", err);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const api = {
|
|
64
|
+
SCHEMES: SCHEMES,
|
|
65
|
+
applyScheme: applyScheme,
|
|
66
|
+
schemeOf: schemeOf,
|
|
67
|
+
schemeFromUrl: schemeFromUrl,
|
|
68
|
+
followHost: followHost
|
|
69
|
+
};
|
|
70
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
71
|
+
module.exports = api;
|
|
72
|
+
} else {
|
|
73
|
+
root.UKC_THEME = api;
|
|
74
|
+
}
|
|
75
|
+
})(typeof globalThis !== "undefined" ? globalThis : this);
|
package/public/ukc-shared.js
CHANGED
|
@@ -6,19 +6,25 @@
|
|
|
6
6
|
/** Plugin REST base; the pages are same-origin with the Signal K server. */
|
|
7
7
|
const API = "/plugins/signalk-underkeel/v1";
|
|
8
8
|
|
|
9
|
+
/** Each state's label; its colours are theme.css's `--st-<state>` tokens. */
|
|
9
10
|
const STATES = {
|
|
10
|
-
normal: { label: "Normal"
|
|
11
|
-
alert: { label: "Alert"
|
|
12
|
-
warn: { label: "Warning"
|
|
13
|
-
alarm: { label: "ALARM"
|
|
14
|
-
unknown: { label: "Unknown"
|
|
15
|
-
off: { label: "Off"
|
|
11
|
+
normal: { label: "Normal" },
|
|
12
|
+
alert: { label: "Alert" },
|
|
13
|
+
warn: { label: "Warning" },
|
|
14
|
+
alarm: { label: "ALARM" },
|
|
15
|
+
unknown: { label: "Unknown" },
|
|
16
|
+
off: { label: "Off" }
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
function stateStyle(state) {
|
|
19
20
|
return STATES[state] || STATES.unknown;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** The state a page colours: one of STATES, unknown for anything else (theme.css `--st-*`). */
|
|
24
|
+
function stateKey(state) {
|
|
25
|
+
return Object.prototype.hasOwnProperty.call(STATES, state) ? state : "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/** Metres in the user's depth unit ("m" or "foot"); "—" when there is no number. */
|
|
23
29
|
function depth(metres, unit) {
|
|
24
30
|
if (typeof metres !== "number" || !isFinite(metres)) {
|
|
@@ -35,16 +41,17 @@
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
|
-
* The status widget's texts and
|
|
44
|
+
* The status widget's texts and state at `now` (`key`, whose colours theme.css holds). A stale view, or one past
|
|
39
45
|
* its `freshUntil`, reads Unknown with no numbers, whatever values it last
|
|
40
46
|
* held: a timer can fire late in a hidden page, the clock cannot.
|
|
41
47
|
*/
|
|
42
48
|
function widgetView(view, unit, now) {
|
|
43
49
|
const stale = view.stale || (typeof view.freshUntil === "number" && !(now < view.freshUntil));
|
|
44
|
-
const
|
|
50
|
+
const key = stateKey(stale ? "unknown" : view.state);
|
|
51
|
+
const s = stateStyle(key);
|
|
45
52
|
const tts = stale ? "" : timeToShoal(view.timeToShoal);
|
|
46
53
|
return {
|
|
47
|
-
|
|
54
|
+
key: key,
|
|
48
55
|
state: "UKC " + s.label,
|
|
49
56
|
line: "min " + depth(stale ? null : view.clearance, unit) + (tts ? " · shoal " + tts : ""),
|
|
50
57
|
reason: stale ? "no data from the under-keel plugin" : reason(view.message)
|
|
@@ -422,6 +429,7 @@
|
|
|
422
429
|
retryAfterMs: retryAfterMs,
|
|
423
430
|
STALE_MS: STALE_MS,
|
|
424
431
|
widgetValue: widgetValue,
|
|
432
|
+
stateKey: stateKey,
|
|
425
433
|
freshFor: freshFor,
|
|
426
434
|
freshness: freshness,
|
|
427
435
|
routeBadge: routeBadge,
|
package/public/widget.html
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<title>Under-keel status</title>
|
|
6
|
+
<link rel="stylesheet" href="theme.css" />
|
|
6
7
|
<style>
|
|
7
8
|
html, body { margin: 0; height: 100%; font: 13px/1.25 system-ui, sans-serif; }
|
|
8
|
-
body { box-sizing: border-box; padding: 6px 8px; cursor: pointer; color:
|
|
9
|
+
body { box-sizing: border-box; padding: 6px 8px; cursor: pointer; color: var(--on-st-unknown); background: var(--st-unknown); }
|
|
10
|
+
body[data-state="normal"] { color: var(--on-st-normal); background: var(--st-normal); }
|
|
11
|
+
body[data-state="alert"] { color: var(--on-st-alert); background: var(--st-alert); }
|
|
12
|
+
body[data-state="warn"] { color: var(--on-st-warn); background: var(--st-warn); }
|
|
13
|
+
body[data-state="alarm"] { color: var(--on-st-alarm); background: var(--st-alarm); }
|
|
9
14
|
#state { font-size: 18px; font-weight: 700; letter-spacing: 0.02em; }
|
|
10
15
|
#line { margin-top: 2px; }
|
|
11
|
-
#reason { margin-top: 2px; font-size: 11px;
|
|
12
|
-
#advisory { margin-top: 2px; font-size: 10px;
|
|
16
|
+
#reason { margin-top: 2px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
17
|
+
#advisory { margin-top: 2px; font-size: 10px; }
|
|
13
18
|
</style>
|
|
19
|
+
<script src="theme.js"></script>
|
|
14
20
|
<script src="ukc-shared.js"></script>
|
|
15
21
|
<script type="module" src="widget.js"></script>
|
|
16
22
|
</head>
|
package/public/widget.js
CHANGED
|
@@ -3,6 +3,8 @@ import { connectExtension } from "./vendor/plotterext-bus/extension.js";
|
|
|
3
3
|
|
|
4
4
|
const UKC = globalThis.UKC;
|
|
5
5
|
const client = await connectExtension();
|
|
6
|
+
// Not awaited: a host that offers night mode but never answers must not hold back the status.
|
|
7
|
+
void globalThis.UKC_THEME.followHost(client, document);
|
|
6
8
|
const view = { state: "unknown", clearance: null, timeToShoal: null, message: "", stale: false };
|
|
7
9
|
let unit = "m";
|
|
8
10
|
/** A state not seen for this long is shown as unknown (the liveness contract in UKC.STALE_MS). */
|
|
@@ -25,8 +27,8 @@ function render() {
|
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
29
|
const next = UKC.widgetView(view, unit, Date.now());
|
|
28
|
-
if (next.
|
|
29
|
-
document.body.
|
|
30
|
+
if (next.key !== shown.key) {
|
|
31
|
+
document.body.dataset.state = next.key;
|
|
30
32
|
}
|
|
31
33
|
for (const id of ["state", "line", "reason"]) {
|
|
32
34
|
if (next[id] !== shown[id]) {
|