@meri-imperiumi/signalk-passage-briefing 0.2.1 → 0.3.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 +85 -0
- package/package.json +2 -2
- package/plugin/celestial-source.js +7 -1
- package/plugin/index.js +59 -33
- package/public/components/models.mjs +156 -13
- package/public/components/passage-outlook.js +91 -12
- package/public/components/sk-base-css.js +22 -0
- package/public/components/strategic-outlook.js +28 -12
- package/public/components/synoptic-chart.js +21 -0
- package/public/components/tactical-dashboard.js +52 -9
- package/public/icon-256.png +0 -0
- package/public/route-sim.mjs +72 -24
- package/tests/plugin.test.js +97 -0
- package/tests/route-sim.test.js +60 -5
- package/tests/webapp.test.js +90 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.0] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- ETA percentile rows in the strategic outlook flag night arrivals
|
|
10
|
+
with a moon marker: arrival day/night is computed at the
|
|
11
|
+
destination for each of P10/P50/P90.
|
|
12
|
+
|
|
13
|
+
- "No sails" stretches now say why the canvas is down: `No sails -
|
|
14
|
+
drifting` when the plan drifts below the motoring wind threshold,
|
|
15
|
+
`Motoring` when the engine pushes — reconciling the sail-work
|
|
16
|
+
queue with a zero engine-hours plan (drift mode).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Strategic outlook layout: motor hours and fuel use the shared
|
|
21
|
+
stat styling, and the sail-work queue renders as cards (tack/gybe
|
|
22
|
+
highlighted) like the tactical action queue.
|
|
23
|
+
|
|
24
|
+
- "Warnings On Your Waters" and the synoptic surface-analysis chart
|
|
25
|
+
now also appear on the tactical dashboard, not just the strategic
|
|
26
|
+
view; the chart's night palette follows the document mode via its
|
|
27
|
+
own observation (it previously never inverted when embedded).
|
|
28
|
+
Shared card/stat styles moved into the common shadow-DOM base
|
|
29
|
+
stylesheet.
|
|
30
|
+
|
|
31
|
+
- Sail-change events no longer flap when the forecast sits on a
|
|
32
|
+
matrix bin edge: a suggested state must hold through a full
|
|
33
|
+
simulation step before it enters the sail-work queue.
|
|
34
|
+
|
|
35
|
+
- Fuel is handled in liters end to end (SI — no imperial units):
|
|
36
|
+
the motor burn rate is configurable as `Motor Fuel Consumption
|
|
37
|
+
(liters per hour)` with a 1.8 l/h default, and the strategic ETA
|
|
38
|
+
table shows e.g. `72.0 l` instead of gallons.
|
|
39
|
+
|
|
40
|
+
- The tactical "Next 24 Hours" hero readout is labeled `AWS` and
|
|
41
|
+
shows its unit: `AWS 17.0 kn` instead of a bare number. (Signal K
|
|
42
|
+
carries wind in SI m/s internally; the briefing displays the
|
|
43
|
+
nautical kn.)
|
|
44
|
+
|
|
45
|
+
- Sail-change cards in the tactical dashboard and the sail-work
|
|
46
|
+
timeline in the strategic outlook render the canonical sail-state
|
|
47
|
+
keys as human-readable labels: `GENOA_1_30_FURLED_MAIN_1_REEF`
|
|
48
|
+
reads "Genoa 1 30% furled + Main 1 reef", `NO_SAILS` reads "No
|
|
49
|
+
sails". Unparseable keys still fall back to the raw form.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- A scheduled (oneshot/cron) refresh no longer fails forever when
|
|
54
|
+
the last briefed route has been deleted from resources: the stale
|
|
55
|
+
`last-route` pointer is removed and the refresh falls back to
|
|
56
|
+
keeping conditions-here fresh. Previously every cycle died with
|
|
57
|
+
`Briefing refresh failed (oneshot): Resource not found!`.
|
|
58
|
+
|
|
59
|
+
- The scheduled (oneshot/cron) route refresh no longer pulls the
|
|
60
|
+
bulletin stack twice per cycle: the briefing refresh already
|
|
61
|
+
fetches bulletins and synoptics for the track, so the outer
|
|
62
|
+
duplicate pass is gone.
|
|
63
|
+
|
|
64
|
+
- SWPC solar-weather timestamps are UTC but carry no offset, so
|
|
65
|
+
they were parsed in the server's local timezone: on a boat far
|
|
66
|
+
from Greenwich the entire Kp forecast window shifted and aurora
|
|
67
|
+
alerts degraded. Offsetless timestamps are now read as UTC.
|
|
68
|
+
|
|
69
|
+
- Switching the route selector between "Conditions here" and a route
|
|
70
|
+
now actually swaps the view: the tactical/strategic shell is
|
|
71
|
+
rebuilt for the served mode (previously the tabbed views never
|
|
72
|
+
came back after visiting conditions-here, so route selection
|
|
73
|
+
appeared to do nothing). Stale model data from the previous
|
|
74
|
+
selection is dropped instead of flashing.
|
|
75
|
+
|
|
76
|
+
- Selecting "Conditions here" in the route picker now actually
|
|
77
|
+
serves conditions-here: the briefing API treated an empty `route`
|
|
78
|
+
parameter as "serve the route being sailed", so the selection
|
|
79
|
+
silently returned the same route view. An explicit `?route=`
|
|
80
|
+
(even empty) now selects; only a fully omitted parameter falls
|
|
81
|
+
back to the active route.
|
|
82
|
+
|
|
83
|
+
- The webapp shows a loading state while a briefing loads or
|
|
84
|
+
refreshes (compiles can take tens of seconds on a slow link —
|
|
85
|
+
silence read as a broken app), and timed-out requests say the
|
|
86
|
+
server is busy and to retry instead of surfacing the cryptic
|
|
87
|
+
engine abort text. Rapid mode switching can no longer apply a
|
|
88
|
+
stale response after a newer one.
|
|
89
|
+
|
|
5
90
|
## [0.2.1] - 2026-09-28
|
|
6
91
|
|
|
7
92
|
## [0.2.0] - 2026-09-28
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-passage-briefing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Offshore passage daily briefing webapp for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"signalk-plugin-enabled-by-default": false,
|
|
27
27
|
"signalk": {
|
|
28
28
|
"displayName": "Passage Briefing",
|
|
29
|
-
"appIcon": "./icon.png",
|
|
29
|
+
"appIcon": "./icon-256.png",
|
|
30
30
|
"screenshots": [
|
|
31
31
|
"doc/here-brief.png"
|
|
32
32
|
],
|
|
@@ -87,7 +87,13 @@ function parseKpForecast(json, { from = new Date(), hours = 24 } = {}) {
|
|
|
87
87
|
const end = start + hours * 3600000;
|
|
88
88
|
const entries = [];
|
|
89
89
|
for (const row of json) {
|
|
90
|
-
|
|
90
|
+
// SWPC time_tag is UTC but offsetless: parsing it bare would
|
|
91
|
+
// interpret it in the server's timezone and shift the whole
|
|
92
|
+
// window (a UTC+13 boat must see the same forecast as a UTC one)
|
|
93
|
+
const raw = typeof row?.time_tag === "string" ? row.time_tag : "";
|
|
94
|
+
const t = new Date(
|
|
95
|
+
/[Zz]|[+-]\d\d:?\d\d$/.test(raw) ? raw : `${raw}Z`,
|
|
96
|
+
).getTime();
|
|
91
97
|
const kp = row?.kp;
|
|
92
98
|
if (!Number.isFinite(t) || typeof kp !== "number" || !Number.isFinite(kp)) {
|
|
93
99
|
continue;
|
package/plugin/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const { join } = require("node:path");
|
|
22
22
|
const { homedir } = require("node:os");
|
|
23
|
-
const { mkdir, readFile, writeFile } = require("node:fs/promises");
|
|
23
|
+
const { mkdir, readFile, unlink, writeFile } = require("node:fs/promises");
|
|
24
24
|
|
|
25
25
|
const { PassageStateMachine } = require("./state-machine.js");
|
|
26
26
|
const { PassageDatabase } = require("./sqlite-db.js");
|
|
@@ -125,6 +125,7 @@ const HERE_TTL_MS = 3 * 60 * 60 * 1000;
|
|
|
125
125
|
const DEFAULTS = {
|
|
126
126
|
motoring_tws_threshold: 3.5,
|
|
127
127
|
drift_mode_enabled: true,
|
|
128
|
+
motor_fuel_l_per_hour: 1.8,
|
|
128
129
|
waterline_length_m: 9.4,
|
|
129
130
|
spool_directory: join(homedir(), ".signalk", "spool", "passage-outlook"),
|
|
130
131
|
k_heel: 0.35,
|
|
@@ -168,6 +169,7 @@ module.exports = (app) => {
|
|
|
168
169
|
let simulationConfig = {
|
|
169
170
|
motoring_tws_threshold: DEFAULTS.motoring_tws_threshold,
|
|
170
171
|
drift_mode_enabled: DEFAULTS.drift_mode_enabled,
|
|
172
|
+
motor_fuel_l_per_hour: DEFAULTS.motor_fuel_l_per_hour,
|
|
171
173
|
waterline_length_m: DEFAULTS.waterline_length_m,
|
|
172
174
|
k_heel: DEFAULTS.k_heel,
|
|
173
175
|
k_pitch: DEFAULTS.k_pitch,
|
|
@@ -631,7 +633,12 @@ module.exports = (app) => {
|
|
|
631
633
|
}
|
|
632
634
|
let coordinates;
|
|
633
635
|
if (typeof app.resourcesApi?.getResource === "function") {
|
|
634
|
-
|
|
636
|
+
let route;
|
|
637
|
+
try {
|
|
638
|
+
route = await app.resourcesApi.getResource("routes", routeId);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
throw new Error(`Route ${routeId} not found (${error.message})`);
|
|
641
|
+
}
|
|
635
642
|
coordinates = route?.feature?.geometry?.coordinates;
|
|
636
643
|
}
|
|
637
644
|
if (!Array.isArray(coordinates) || coordinates.length < 2) {
|
|
@@ -687,6 +694,12 @@ module.exports = (app) => {
|
|
|
687
694
|
* trigger). The multi-endpoint fetch lands in the briefing refresh;
|
|
688
695
|
* the outcome is surfaced through the plugin status.
|
|
689
696
|
*
|
|
697
|
+
* The route being sailed wins; otherwise the last briefed one is
|
|
698
|
+
* re-briefed. A route that has vanished from resources (deleted
|
|
699
|
+
* after it was briefed) is skipped instead of failing every future
|
|
700
|
+
* refresh — with no route left, conditions-here stays fresh (work
|
|
701
|
+
* doc #7).
|
|
702
|
+
*
|
|
690
703
|
* @param {"oneshot"|"cron"} trigger
|
|
691
704
|
*/
|
|
692
705
|
async function runFetch(trigger) {
|
|
@@ -694,41 +707,41 @@ module.exports = (app) => {
|
|
|
694
707
|
setStatus(`Fetch skipped while offline (${trigger})`);
|
|
695
708
|
return;
|
|
696
709
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
await readFile(
|
|
704
|
-
join(app.getDataDirPath(), "weather", "last-route"),
|
|
705
|
-
"utf8",
|
|
706
|
-
)
|
|
707
|
-
).trim();
|
|
708
|
-
} catch (_error) {
|
|
709
|
-
routeId = null;
|
|
710
|
-
}
|
|
710
|
+
const lastRouteFile = join(app.getDataDirPath(), "weather", "last-route");
|
|
711
|
+
let lastRouteId = null;
|
|
712
|
+
try {
|
|
713
|
+
lastRouteId = (await readFile(lastRouteFile, "utf8")).trim();
|
|
714
|
+
} catch (_error) {
|
|
715
|
+
lastRouteId = null;
|
|
711
716
|
}
|
|
712
|
-
|
|
717
|
+
const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
|
|
718
|
+
const candidates = [...new Set([activeId, lastRouteId].filter(Boolean))];
|
|
719
|
+
for (const routeId of candidates) {
|
|
713
720
|
try {
|
|
714
|
-
const
|
|
715
|
-
setStatus(
|
|
721
|
+
const result = await refreshBriefing(routeId);
|
|
722
|
+
setStatus(
|
|
723
|
+
`Briefing for ${routeId} cached at ${result.fetchedAt} (${trigger})`,
|
|
724
|
+
);
|
|
725
|
+
return;
|
|
716
726
|
} catch (error) {
|
|
717
|
-
|
|
718
|
-
|
|
727
|
+
if (!/not found/i.test(error.message)) {
|
|
728
|
+
app.error(`Briefing refresh failed (${trigger}): ${error.message}`);
|
|
729
|
+
setError(`Briefing refresh failed (${trigger}): ${error.message}`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
// Route deleted since it was briefed: try the next candidate
|
|
733
|
+
app.debug?.(`Route ${routeId} no longer exists (${trigger})`);
|
|
734
|
+
if (routeId === lastRouteId && routeId !== activeId) {
|
|
735
|
+
await unlink(lastRouteFile).catch(() => {});
|
|
736
|
+
}
|
|
719
737
|
}
|
|
720
|
-
return;
|
|
721
738
|
}
|
|
722
739
|
try {
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
await refreshBulletinsOnline(trigger, result.waypoints);
|
|
726
|
-
setStatus(
|
|
727
|
-
`Briefing for ${routeId} cached at ${result.fetchedAt} (${trigger})`,
|
|
728
|
-
);
|
|
740
|
+
const here = await refreshHere();
|
|
741
|
+
setStatus(`Conditions here cached at ${here.cachedAt} (${trigger})`);
|
|
729
742
|
} catch (error) {
|
|
730
|
-
app.error(`
|
|
731
|
-
setError(`
|
|
743
|
+
app.error(`Here refresh failed (${trigger}): ${error.message}`);
|
|
744
|
+
setError(`Here refresh failed (${trigger}): ${error.message}`);
|
|
732
745
|
}
|
|
733
746
|
}
|
|
734
747
|
|
|
@@ -951,6 +964,14 @@ module.exports = (app) => {
|
|
|
951
964
|
title: "Enable Drift Mode (Zero Fuel / Current Drift)",
|
|
952
965
|
default: DEFAULTS.drift_mode_enabled,
|
|
953
966
|
},
|
|
967
|
+
motor_fuel_l_per_hour: {
|
|
968
|
+
type: "number",
|
|
969
|
+
title: "Motor Fuel Consumption (liters per hour)",
|
|
970
|
+
description:
|
|
971
|
+
"Burn rate of the auxiliary at its passage speed, used when " +
|
|
972
|
+
"drift mode is off and the wind is below the motoring threshold.",
|
|
973
|
+
default: DEFAULTS.motor_fuel_l_per_hour,
|
|
974
|
+
},
|
|
954
975
|
waterline_length_m: {
|
|
955
976
|
type: "number",
|
|
956
977
|
title: "Waterline Length (meters)",
|
|
@@ -1034,6 +1055,7 @@ module.exports = (app) => {
|
|
|
1034
1055
|
simulationConfig = {
|
|
1035
1056
|
motoring_tws_threshold: config.motoring_tws_threshold,
|
|
1036
1057
|
drift_mode_enabled: config.drift_mode_enabled,
|
|
1058
|
+
motor_fuel_l_per_hour: config.motor_fuel_l_per_hour,
|
|
1037
1059
|
waterline_length_m: config.waterline_length_m,
|
|
1038
1060
|
k_heel: config.k_heel,
|
|
1039
1061
|
k_pitch: config.k_pitch,
|
|
@@ -1247,10 +1269,14 @@ module.exports = (app) => {
|
|
|
1247
1269
|
* everything else is here mode.
|
|
1248
1270
|
*/
|
|
1249
1271
|
router.get("/api/briefing", async (req, res) => {
|
|
1250
|
-
|
|
1251
|
-
|
|
1272
|
+
// An explicit ?route= (even empty) selects the mode — the
|
|
1273
|
+
// route picker's "Conditions here" entry requests exactly
|
|
1274
|
+
// that. With no param at all the route being sailed wins,
|
|
1275
|
+
// falling back to here when none (work doc #7)
|
|
1276
|
+
const explicitRoute = typeof req.query.route === "string";
|
|
1277
|
+
const routeId = explicitRoute ? req.query.route.trim() : "";
|
|
1252
1278
|
const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
|
|
1253
|
-
const effectiveRoute = routeId || activeId;
|
|
1279
|
+
const effectiveRoute = routeId || (explicitRoute ? "" : activeId);
|
|
1254
1280
|
if (!effectiveRoute) {
|
|
1255
1281
|
const here = await loadHere();
|
|
1256
1282
|
if (!here) {
|
|
@@ -88,16 +88,16 @@ export function fmtKn(knots) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Formats fuel in
|
|
91
|
+
* Formats fuel in liters with one decimal (SI — never imperial).
|
|
92
92
|
*
|
|
93
|
-
* @param {number|null|undefined}
|
|
93
|
+
* @param {number|null|undefined} liters
|
|
94
94
|
* @returns {string} Empty string when unset/invalid
|
|
95
95
|
*/
|
|
96
|
-
export function
|
|
97
|
-
if (
|
|
96
|
+
export function fmtLiters(liters) {
|
|
97
|
+
if (liters == null || !Number.isFinite(liters)) {
|
|
98
98
|
return "";
|
|
99
99
|
}
|
|
100
|
-
return `${
|
|
100
|
+
return `${liters.toFixed(1)} l`;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
@@ -300,19 +300,148 @@ export function tacticalNow(exceptions) {
|
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Sail-type words that mark the start of a new sail component inside
|
|
305
|
+
* a canonical sail-state key. Reef/furl bits always terminate their
|
|
306
|
+
* component, so only consecutive *full* sails are ambiguous without
|
|
307
|
+
* the vessel's sail inventory — this small dictionary of common sail
|
|
308
|
+
* types closes the gap (e.g. `GENOA_1_MAIN` is two sails, not one
|
|
309
|
+
* named "Genoa 1 Main").
|
|
310
|
+
*/
|
|
311
|
+
const SAIL_COMPONENT_WORDS = new Set([
|
|
312
|
+
"MAIN",
|
|
313
|
+
"GENOA",
|
|
314
|
+
"JIB",
|
|
315
|
+
"STAYSAIL",
|
|
316
|
+
"FORESAIL",
|
|
317
|
+
"MIZZEN",
|
|
318
|
+
"SPINNAKER",
|
|
319
|
+
"GENNAKER",
|
|
320
|
+
"YANKEE",
|
|
321
|
+
"FOCK",
|
|
322
|
+
"CODE",
|
|
323
|
+
"DRIFTER",
|
|
324
|
+
"SPIN",
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Human-readable label for a canonical sail-state key (as built by
|
|
329
|
+
* `logbook-source.js` `sailStateKey`): `GENOA_1_30_FURLED_MAIN_1_REEF`
|
|
330
|
+
* reads as `Genoa 1 30% furled + Main 1 reef`, `NO_SAILS` as
|
|
331
|
+
* `No sails`. A trailing `@side` (maneuver states) is ignored.
|
|
332
|
+
*
|
|
333
|
+
* @param {string|null|undefined} key - Canonical key, e.g. `MAIN_1_REEF`
|
|
334
|
+
* @returns {string} Label, or the raw input when nothing parses
|
|
335
|
+
*/
|
|
336
|
+
export function sailStateLabel(key) {
|
|
337
|
+
const raw = String(key ?? "").trim();
|
|
338
|
+
if (!raw) {
|
|
339
|
+
return "";
|
|
340
|
+
}
|
|
341
|
+
const combination = raw.split("@")[0];
|
|
342
|
+
if (!combination || combination === "?") {
|
|
343
|
+
return raw;
|
|
344
|
+
}
|
|
345
|
+
if (combination === "NO_SAILS") {
|
|
346
|
+
return "No sails";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const components = [];
|
|
350
|
+
let words = [];
|
|
351
|
+
let reefs = null;
|
|
352
|
+
let furled = null;
|
|
353
|
+
const flush = () => {
|
|
354
|
+
if (words.length > 0 || reefs != null || furled != null) {
|
|
355
|
+
components.push({ words, reefs, furled });
|
|
356
|
+
}
|
|
357
|
+
words = [];
|
|
358
|
+
reefs = null;
|
|
359
|
+
furled = null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const tokens = combination.split("_");
|
|
363
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
364
|
+
const token = tokens[i];
|
|
365
|
+
const value = /^\d+$/.test(token) ? Number(token) : null;
|
|
366
|
+
const next = tokens[i + 1];
|
|
367
|
+
if (value != null && (next === "REEF" || next === "FURLED")) {
|
|
368
|
+
// Bit run `N REEF` / `N FURLED`: ends the component name run
|
|
369
|
+
if (next === "REEF") {
|
|
370
|
+
reefs = value;
|
|
371
|
+
} else {
|
|
372
|
+
furled = value;
|
|
373
|
+
}
|
|
374
|
+
i++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
words.length > 0 &&
|
|
379
|
+
(reefs != null || furled != null || SAIL_COMPONENT_WORDS.has(token))
|
|
380
|
+
) {
|
|
381
|
+
flush();
|
|
382
|
+
}
|
|
383
|
+
words.push(token);
|
|
384
|
+
}
|
|
385
|
+
flush();
|
|
386
|
+
|
|
387
|
+
if (components.length === 0) {
|
|
388
|
+
return raw;
|
|
389
|
+
}
|
|
390
|
+
return components
|
|
391
|
+
.map(({ words: sailWords, reefs: nReef, furled: nFurled }) => {
|
|
392
|
+
const name = sailWords
|
|
393
|
+
.map((word) =>
|
|
394
|
+
/^\d/.test(word)
|
|
395
|
+
? word
|
|
396
|
+
: word.charAt(0) + word.slice(1).toLowerCase(),
|
|
397
|
+
)
|
|
398
|
+
.join(" ");
|
|
399
|
+
const bits = [];
|
|
400
|
+
if (nReef != null) {
|
|
401
|
+
bits.push(`${nReef} reef${nReef === 1 ? "" : "s"}`);
|
|
402
|
+
}
|
|
403
|
+
if (nFurled != null) {
|
|
404
|
+
bits.push(`${nFurled}% furled`);
|
|
405
|
+
}
|
|
406
|
+
return [name, ...bits].filter(Boolean).join(" ");
|
|
407
|
+
})
|
|
408
|
+
.join(" + ");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Label for one sail-change event: bare "No sails" says WHY the
|
|
413
|
+
* canvas is down — the plan drifts (`No sails - drifting`) or the
|
|
414
|
+
* engine pushes (`Motoring`) — instead of implying bare-pole sailing.
|
|
415
|
+
*
|
|
416
|
+
* @param {{sailState: string|null, propulsion?: string|null}} event
|
|
417
|
+
* @returns {string} Human label
|
|
418
|
+
*/
|
|
419
|
+
function sailEventLabel(event) {
|
|
420
|
+
const label = sailStateLabel(event.sailState);
|
|
421
|
+
if (label === "No sails") {
|
|
422
|
+
if (event.propulsion === "adrift") {
|
|
423
|
+
return "No sails - drifting";
|
|
424
|
+
}
|
|
425
|
+
if (event.propulsion === "motor") {
|
|
426
|
+
return "Motoring";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return label;
|
|
430
|
+
}
|
|
431
|
+
|
|
303
432
|
/**
|
|
304
433
|
* Sail action cards for the tactical dashboard, oldest first.
|
|
305
434
|
* Maneuver events (work doc #5) carry `maneuver`, `toTack` and the
|
|
306
435
|
* expected TWS so the cards can read "Tack to starboard ~14:20, 12 kt".
|
|
307
436
|
*
|
|
308
437
|
* @param {object|null} exceptions
|
|
309
|
-
* @returns {Array<{hoursFromNow: number, stamp: string,
|
|
438
|
+
* @returns {Array<{hoursFromNow: number, stamp: string, label: string, night: boolean, maneuver: string|null, toTack: string|null, twsKnots: number|null}>}
|
|
310
439
|
*/
|
|
311
440
|
export function sailActionCards(exceptions) {
|
|
312
441
|
return (exceptions?.next24h?.sailChanges ?? []).map((e) => ({
|
|
313
442
|
hoursFromNow: e.hoursFromNow,
|
|
314
443
|
stamp: fmtUtc(e.timestamp),
|
|
315
|
-
|
|
444
|
+
label: sailEventLabel(e) || "?",
|
|
316
445
|
night: Boolean(e.night),
|
|
317
446
|
maneuver: e.maneuver ?? null,
|
|
318
447
|
toTack: e.toTack ?? null,
|
|
@@ -330,10 +459,10 @@ export function sailActionCards(exceptions) {
|
|
|
330
459
|
export function sailWorkTimeline(exceptions) {
|
|
331
460
|
return (exceptions?.passageSummary?.sailChanges ?? []).map((e) => {
|
|
332
461
|
const state = String(e.sailState ?? "?");
|
|
333
|
-
const [
|
|
462
|
+
const [, tack] = state.split("@");
|
|
334
463
|
const label = e.maneuver
|
|
335
464
|
? `${e.maneuver === "tack" ? "Tack" : "Gybe"} to ${e.toTack ?? tack ?? "?"}`
|
|
336
|
-
:
|
|
465
|
+
: sailEventLabel(e) || state;
|
|
337
466
|
const detail = [
|
|
338
467
|
e.distanceFromStartNm != null
|
|
339
468
|
? `${Math.round(e.distanceFromStartNm)} nm`
|
|
@@ -359,14 +488,28 @@ export function sailWorkTimeline(exceptions) {
|
|
|
359
488
|
*/
|
|
360
489
|
export function etaTable(exceptions) {
|
|
361
490
|
const summary = exceptions?.passageSummary ?? {};
|
|
491
|
+
const night = summary.etaNight ?? {};
|
|
362
492
|
return {
|
|
363
493
|
rows: [
|
|
364
|
-
{
|
|
365
|
-
|
|
366
|
-
|
|
494
|
+
{
|
|
495
|
+
label: "P10",
|
|
496
|
+
stamp: fmtUtc(summary.etaP10),
|
|
497
|
+
night: night.p10 === true,
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
label: "P50",
|
|
501
|
+
stamp: fmtUtc(summary.etaP50),
|
|
502
|
+
night: night.p50 === true,
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
label: "P90",
|
|
506
|
+
stamp: fmtUtc(summary.etaP90),
|
|
507
|
+
night: night.p90 === true,
|
|
508
|
+
},
|
|
367
509
|
],
|
|
368
510
|
motorHours:
|
|
369
511
|
summary.totalMotorHours != null ? fmtHours(summary.totalMotorHours) : "",
|
|
370
|
-
fuel:
|
|
512
|
+
fuel:
|
|
513
|
+
summary.totalFuelLiters != null ? fmtLiters(summary.totalFuelLiters) : "",
|
|
371
514
|
};
|
|
372
515
|
}
|
|
@@ -21,6 +21,22 @@ import { SK_BASE_CSS } from "./sk-base-css.js";
|
|
|
21
21
|
*/
|
|
22
22
|
const PLUGIN_API = "/plugins/signalk-passage-briefing/api";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Friendly text for fetch failures: the 8 s/120 s abort timeouts
|
|
26
|
+
* surface with cryptic engine-specific messages ("Fetch is aborted",
|
|
27
|
+
* "The operation was aborted") — say what actually happened instead.
|
|
28
|
+
*
|
|
29
|
+
* @param {Error} error
|
|
30
|
+
* @param {string} fallback - Prefix for non-abort failures
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function fetchErrorMessage(error, fallback) {
|
|
34
|
+
if (error?.name === "AbortError" || /abort/i.test(error?.message ?? "")) {
|
|
35
|
+
return "The server took too long to answer — it may be busy compiling a briefing. Try again.";
|
|
36
|
+
}
|
|
37
|
+
return `${fallback}: ${error.message}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/**
|
|
25
41
|
* The custom element (browser only).
|
|
26
42
|
*/
|
|
@@ -59,6 +75,13 @@ class PassageOutlook extends HTMLElement {
|
|
|
59
75
|
align-items: center; flex-wrap: wrap;
|
|
60
76
|
}
|
|
61
77
|
.stale button { min-height: 40px; padding: 8px 12px; }
|
|
78
|
+
.loading {
|
|
79
|
+
border: 1px solid var(--color-grey);
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
font-family: var(--font-data, ui-monospace, monospace);
|
|
82
|
+
letter-spacing: 0.1em; text-transform: uppercase;
|
|
83
|
+
padding: 8px 12px; margin-bottom: 12px;
|
|
84
|
+
}
|
|
62
85
|
</style>
|
|
63
86
|
<header>
|
|
64
87
|
<h1>Passage Briefing</h1>
|
|
@@ -98,6 +121,7 @@ class PassageOutlook extends HTMLElement {
|
|
|
98
121
|
|
|
99
122
|
this._worker = null;
|
|
100
123
|
this._briefing = null;
|
|
124
|
+
this._briefingKey = null;
|
|
101
125
|
this._exceptions = null;
|
|
102
126
|
|
|
103
127
|
this.renderRoute();
|
|
@@ -183,9 +207,20 @@ class PassageOutlook extends HTMLElement {
|
|
|
183
207
|
* @param {string} routeId - Route resource id, or "" for here mode
|
|
184
208
|
*/
|
|
185
209
|
async loadBriefing(routeId) {
|
|
186
|
-
|
|
210
|
+
// Always send ?route=: the empty string is an explicit request
|
|
211
|
+
// for conditions-here, not an omission (the server otherwise
|
|
212
|
+
// serves the route being sailed)
|
|
213
|
+
const query = `?route=${encodeURIComponent(routeId)}`;
|
|
214
|
+
// Sequence token: only the newest selection may paint, so rapid
|
|
215
|
+
// switching cannot apply a stale response
|
|
216
|
+
this._loadSeq = (this._loadSeq ?? 0) + 1;
|
|
217
|
+
const seq = this._loadSeq;
|
|
218
|
+
this.renderLoading();
|
|
187
219
|
try {
|
|
188
|
-
this._briefing = await fetchJson(`${PLUGIN_API}/briefing${query}
|
|
220
|
+
this._briefing = await fetchJson(`${PLUGIN_API}/briefing${query}`, 20000);
|
|
221
|
+
if (seq !== this._loadSeq) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
189
224
|
if (!this._briefing.payload) {
|
|
190
225
|
// A never-briefed route: fetch it once automatically (online)
|
|
191
226
|
// instead of requiring the button — but only once per route,
|
|
@@ -197,18 +232,35 @@ class PassageOutlook extends HTMLElement {
|
|
|
197
232
|
await this.refreshBriefing();
|
|
198
233
|
return;
|
|
199
234
|
}
|
|
235
|
+
const mode = this._briefing.mode;
|
|
200
236
|
this._briefing = null;
|
|
201
|
-
this.
|
|
237
|
+
this.renderRoute();
|
|
238
|
+
this.renderStale(true, mode);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (seq !== this._loadSeq) {
|
|
202
242
|
return;
|
|
203
243
|
}
|
|
244
|
+
// Route change: drop cached exceptions from the previous mode,
|
|
245
|
+
// they belong to another track (or none at all)
|
|
246
|
+
const key = this._briefing.routeId ?? "here";
|
|
247
|
+
if (key !== this._briefingKey) {
|
|
248
|
+
this._briefingKey = key;
|
|
249
|
+
this._exceptions = null;
|
|
250
|
+
}
|
|
204
251
|
this.renderStale(false);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
252
|
+
// Rebuild the view shell for the served mode: leaving "Conditions
|
|
253
|
+
// here" must bring the tactical/strategic tabs back, and the
|
|
254
|
+
// worker's results render into the elements this creates
|
|
255
|
+
this.renderRoute();
|
|
256
|
+
if (this._briefing.mode !== "here") {
|
|
208
257
|
this.simulate();
|
|
209
258
|
}
|
|
210
259
|
} catch (error) {
|
|
211
|
-
|
|
260
|
+
if (seq !== this._loadSeq) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.showError(fetchErrorMessage(error, "Briefing unavailable"));
|
|
212
264
|
}
|
|
213
265
|
}
|
|
214
266
|
|
|
@@ -216,22 +268,44 @@ class PassageOutlook extends HTMLElement {
|
|
|
216
268
|
async refreshBriefing() {
|
|
217
269
|
const routeId = this._routeSelect.value;
|
|
218
270
|
const query = routeId ? `?route=${encodeURIComponent(routeId)}` : "";
|
|
271
|
+
this.renderLoading(
|
|
272
|
+
routeId
|
|
273
|
+
? "Fetching briefing — can take a minute over a slow link…"
|
|
274
|
+
: "Fetching conditions…",
|
|
275
|
+
);
|
|
219
276
|
try {
|
|
220
277
|
await fetchJson(`${PLUGIN_API}/briefing/refresh${query}`, 120000, {
|
|
221
278
|
method: "POST",
|
|
222
279
|
});
|
|
223
280
|
await this.loadBriefing(routeId);
|
|
224
281
|
} catch (error) {
|
|
225
|
-
this.showError(
|
|
282
|
+
this.showError(fetchErrorMessage(error, "Refresh failed"));
|
|
226
283
|
}
|
|
227
284
|
}
|
|
228
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Loading strip replacing the view while a fetch or refresh runs:
|
|
288
|
+
* briefing compiles can take tens of seconds on a slow link, and
|
|
289
|
+
* silence reads as a broken app.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} [message]
|
|
292
|
+
*/
|
|
293
|
+
renderLoading(message = "Loading briefing…") {
|
|
294
|
+
this.shadowRoot.getElementById("stale")?.remove();
|
|
295
|
+
this._view.innerHTML = "";
|
|
296
|
+
const strip = document.createElement("div");
|
|
297
|
+
strip.className = "loading";
|
|
298
|
+
strip.textContent = message;
|
|
299
|
+
this._view.appendChild(strip);
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
/**
|
|
230
303
|
* "No cached briefing yet" strip with the refresh affordance.
|
|
231
304
|
*
|
|
232
305
|
* @param {boolean} show
|
|
306
|
+
* @param {string} [mode] - Served mode the strip refers to
|
|
233
307
|
*/
|
|
234
|
-
renderStale(show) {
|
|
308
|
+
renderStale(show, mode = "route") {
|
|
235
309
|
this.shadowRoot.getElementById("stale")?.remove();
|
|
236
310
|
if (!show) {
|
|
237
311
|
return;
|
|
@@ -240,7 +314,10 @@ class PassageOutlook extends HTMLElement {
|
|
|
240
314
|
strip.className = "stale";
|
|
241
315
|
strip.id = "stale";
|
|
242
316
|
const text = document.createElement("span");
|
|
243
|
-
text.textContent =
|
|
317
|
+
text.textContent =
|
|
318
|
+
mode === "here"
|
|
319
|
+
? "No cached conditions yet"
|
|
320
|
+
: "No cached briefing for this route";
|
|
244
321
|
const button = document.createElement("button");
|
|
245
322
|
button.textContent = "Fetch now";
|
|
246
323
|
button.disabled = this._status?.online !== true;
|
|
@@ -336,8 +413,10 @@ class PassageOutlook extends HTMLElement {
|
|
|
336
413
|
);
|
|
337
414
|
if (target) {
|
|
338
415
|
target.setExceptions(this._exceptions);
|
|
339
|
-
if (
|
|
340
|
-
|
|
416
|
+
if (this._briefing?.payload) {
|
|
417
|
+
// Both screens carry the warning blocks; the raw bulletin
|
|
418
|
+
// console stays strategic-only
|
|
419
|
+
target.setBulletin?.(this._briefing.payload.metareaBulletin ?? null);
|
|
341
420
|
}
|
|
342
421
|
this.pushSpaceEvents(target);
|
|
343
422
|
}
|