@meri-imperiumi/signalk-passage-briefing 0.12.0 → 0.12.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 +14 -0
- package/package.json +2 -2
- package/plugin/fetch-engine.js +120 -1
- package/plugin/index.js +19 -0
- package/plugin/weather-source.js +5 -1
- package/public/components/passage-outlook.js +10 -2
- package/public/components/sk-api.js +19 -24
- package/public/components/tactical-dashboard.js +37 -1
- package/tests/fetch-engine.test.js +132 -1
- package/tests/openmeteo-mock.js +2 -0
- package/tests/plugin.test.js +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.2] - 2026-10-16
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Briefing payloads stopped carrying every forecast hour at every waypoint: the waypoints now ride an ETA-window throttle (a 15-waypoint / 7-day route went from ~1 MB to ~340 KB of JSON with no rendering change). The window opens 12 h before the arrival a fast boat (10 kn) would make and closes 48 h after the arrival a slow boat (2.5 kn) would make — steps outside are dropped, since the boat reads its neighbouring waypoints' data then — and inside the window the hourly steps beyond the first 24 h collapse to every third hour, the wave models' own native resolution (`weatherAt` interpolates). The horizon's last step always rides along (the simulator clamps to it), a guaranteed minimum span keeps coverage when the speed estimate is badly wrong, and the throttle applies to both source paths (Open-Meteo and the Weather API provider).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- The webapp console logged three requests that could never succeed (work doc #40/#30 session report): the "Next" compile pill and the sea-legs day checkpoints called `/api/api/status` and `/api/api/sea-legs` — a stray `api/` segment doubled into the plugin base ever since those two UI pieces shipped, 404ing on every load and silently hiding both features. The URLs now match the registered routes. The critical-hazard banner polled the Signal K REST notifications subtree (`vessels/self/notifications/navigation/briefing/hazards`), which 404s whenever no notification was ever raised on the server; the escalation's active list now also serves from the plugin's own `GET /api/hazards/active` (same data the delta stream carries, empty list when nominal) and the banner polls that.
|
|
12
|
+
- The route briefing had no manual refresh affordance on a fresh cache: the "Update now" button only existed in the no-cached strip and the outdated banner, so the crew couldn't regenerate an up-to-date-looking briefing (e.g. to pick up the throttled payload shape or a fresher forecast) — while conditions-here always carried its card-head button. The tactical view's "Next 24 Hours" card head now carries the same "Update now" in the same place, wired to the route refresh and disabled offline like its here-mode twin.
|
|
13
|
+
|
|
14
|
+
## [0.12.1] - 2026-10-16
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Comfort tiers read "No data" on the Open-Meteo source path: the pinned GFS wave model (`ncep_gfswave025`) does not publish Open-Meteo's `wave_peak_period` variable at all (verified live: all-null at every hour while `wave_height`, `swell_wave_period` and `wind_wave_period` answer), so the Sereno resonance gate (`hsMeters != null && tpSeconds != null`) never passed. The fetch now also requests the mean `wave_period` series and falls back to it when peak is absent — a slightly different statistic, but the comfort model gets its period on every source path again. (The Weather API provider path, which maps `water.wavePeriod`, was never affected; briefings served by it kept their tiers.)
|
|
18
|
+
|
|
5
19
|
## [0.12.0] - 2026-10-16
|
|
6
20
|
|
|
7
21
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-passage-briefing",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Offshore passage daily briefing webapp for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"recommends": [
|
|
39
39
|
"signalk-watch-schedule",
|
|
40
|
-
"signalk-ships-time"
|
|
40
|
+
"@meri-imperiumi/signalk-ships-time"
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
package/plugin/fetch-engine.js
CHANGED
|
@@ -59,6 +59,39 @@ const DEFAULT_FORECAST_DAYS = 7;
|
|
|
59
59
|
*/
|
|
60
60
|
const KEEP_SNAPSHOTS = 8;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* One hour in milliseconds.
|
|
64
|
+
*/
|
|
65
|
+
const HOUR_MS = 3600000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Throttle speed bracket: the fastest arrival the window must cover
|
|
69
|
+
* (window opens before it).
|
|
70
|
+
*/
|
|
71
|
+
const THROTTLE_FAST_KN = 10;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throttle speed bracket: the slowest arrival the window must cover
|
|
75
|
+
* (window closes after it, plus the tail margin).
|
|
76
|
+
*/
|
|
77
|
+
const THROTTLE_SLOW_KN = 2.5;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Throttle window: opens this long before the fastest arrival.
|
|
81
|
+
*/
|
|
82
|
+
const THROTTLE_LEAD_HOURS = 12;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Throttle window: closes this long after the slowest arrival.
|
|
86
|
+
*/
|
|
87
|
+
const THROTTLE_TAIL_HOURS = 48;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Throttle resolution: hourly steps inside the first span of the
|
|
91
|
+
* window, every third hour after.
|
|
92
|
+
*/
|
|
93
|
+
const THROTTLE_HOURLY_HOURS = 24;
|
|
94
|
+
|
|
62
95
|
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
|
63
96
|
const MARINE_URL = "https://marine-api.open-meteo.com/v1/marine";
|
|
64
97
|
|
|
@@ -83,6 +116,10 @@ const WAVE_HOURLY = [
|
|
|
83
116
|
"wave_height",
|
|
84
117
|
"wave_direction",
|
|
85
118
|
"wave_peak_period",
|
|
119
|
+
// Mean period: the GFS wave model on Open-Meteo published peak
|
|
120
|
+
// period as all-null during October 2026, so the comfort model's
|
|
121
|
+
// resonance engine falls back to this when peak is absent
|
|
122
|
+
"wave_period",
|
|
86
123
|
"wind_wave_height",
|
|
87
124
|
"wind_wave_period",
|
|
88
125
|
"wind_wave_direction",
|
|
@@ -453,7 +490,9 @@ function buildTimeSteps(surfaceMap, marineMap, currentMap) {
|
|
|
453
490
|
},
|
|
454
491
|
marine: {
|
|
455
492
|
hsCombined: num(marine.wave_height),
|
|
456
|
-
|
|
493
|
+
// Peak period when the model publishes it, else the mean
|
|
494
|
+
// period (see WAVE_HOURLY)
|
|
495
|
+
tpCombined: num(marine.wave_peak_period) ?? num(marine.wave_period),
|
|
457
496
|
dirCombined: num(marine.wave_direction),
|
|
458
497
|
hsSwell: num(marine.swell_wave_height),
|
|
459
498
|
tpSwell: num(marine.swell_wave_period),
|
|
@@ -579,6 +618,7 @@ async function fetchWeatherAlongTrack({
|
|
|
579
618
|
"wave_height",
|
|
580
619
|
"wave_direction",
|
|
581
620
|
"wave_peak_period",
|
|
621
|
+
"wave_period",
|
|
582
622
|
"wind_wave_height",
|
|
583
623
|
"wind_wave_period",
|
|
584
624
|
"wind_wave_direction",
|
|
@@ -607,6 +647,7 @@ async function fetchWeatherAlongTrack({
|
|
|
607
647
|
models.push("marine:smoc_currents");
|
|
608
648
|
}
|
|
609
649
|
const fetchedAt = new Date().toISOString();
|
|
650
|
+
throttleWaypointForecasts(payloadWaypoints, fetchedAt);
|
|
610
651
|
return {
|
|
611
652
|
metadata: {
|
|
612
653
|
fetchedAt,
|
|
@@ -626,6 +667,83 @@ async function fetchWeatherAlongTrack({
|
|
|
626
667
|
};
|
|
627
668
|
}
|
|
628
669
|
|
|
670
|
+
/**
|
|
671
|
+
* ETA-window throttle for waypoint forecasts (briefing size, bergie
|
|
672
|
+
* session: a 15-waypoint / 7-day route carried every hour of the full
|
|
673
|
+
* horizon at every waypoint — over a megabyte of JSON to render a few
|
|
674
|
+
* KB of page). The boat is at waypoint i only around its arrival, and
|
|
675
|
+
* the arrival time depends on boat speed — so the window opens
|
|
676
|
+
* `THROTTLE_LEAD_HOURS` before the arrival a fast boat would make and
|
|
677
|
+
* closes `THROTTLE_TAIL_HOURS` after the arrival a slow boat would
|
|
678
|
+
* make; steps outside are dropped. Inside the window the hourly steps
|
|
679
|
+
* beyond the first `THROTTLE_HOURLY_HOURS` collapse to every third
|
|
680
|
+
* hour: the wave models underneath are 3-hourly anyway, and
|
|
681
|
+
* `weatherAt` interpolates between steps. A guaranteed minimum span
|
|
682
|
+
* keeps coverage even when the estimate is badly wrong, and the
|
|
683
|
+
* horizon's last step always rides along (the sim clamps to it).
|
|
684
|
+
*
|
|
685
|
+
* @param {Array<{distanceFromStartNm: number, forecasts: Array<object>}>}
|
|
686
|
+
* payloadWaypoints - Mutated in place
|
|
687
|
+
* @param {string|number|Date} fetchedAt - The fetch instant the
|
|
688
|
+
* estimates anchor to (the payload's `fetchedAt`)
|
|
689
|
+
* @returns {Array} The same waypoints, forecasts throttled
|
|
690
|
+
*/
|
|
691
|
+
function throttleWaypointForecasts(payloadWaypoints, fetchedAt) {
|
|
692
|
+
const startMs = new Date(fetchedAt).getTime();
|
|
693
|
+
if (!Array.isArray(payloadWaypoints) || !Number.isFinite(startMs)) {
|
|
694
|
+
return payloadWaypoints ?? [];
|
|
695
|
+
}
|
|
696
|
+
for (const waypoint of payloadWaypoints) {
|
|
697
|
+
const steps = waypoint?.forecasts;
|
|
698
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const times = steps.map((step) => new Date(step.timestamp).getTime());
|
|
702
|
+
const firstMs = times[0];
|
|
703
|
+
const lastMs = times[times.length - 1];
|
|
704
|
+
const distNm = Number.isFinite(waypoint.distanceFromStartNm)
|
|
705
|
+
? Math.max(0, waypoint.distanceFromStartNm)
|
|
706
|
+
: 0;
|
|
707
|
+
let windowStart = Math.max(
|
|
708
|
+
firstMs,
|
|
709
|
+
startMs +
|
|
710
|
+
(distNm / THROTTLE_FAST_KN) * HOUR_MS -
|
|
711
|
+
THROTTLE_LEAD_HOURS * HOUR_MS,
|
|
712
|
+
);
|
|
713
|
+
const windowEnd = Math.min(
|
|
714
|
+
lastMs,
|
|
715
|
+
startMs +
|
|
716
|
+
(distNm / THROTTLE_SLOW_KN) * HOUR_MS +
|
|
717
|
+
THROTTLE_TAIL_HOURS * HOUR_MS,
|
|
718
|
+
);
|
|
719
|
+
// Guaranteed minimum span: a wildly wrong speed estimate must not
|
|
720
|
+
// strip a waypoint bare
|
|
721
|
+
if (windowEnd - windowStart < 48 * HOUR_MS) {
|
|
722
|
+
windowStart = Math.max(firstMs, windowEnd - 48 * HOUR_MS);
|
|
723
|
+
}
|
|
724
|
+
const inWindow = steps.filter(
|
|
725
|
+
(_step, i) => times[i] >= windowStart && times[i] <= windowEnd,
|
|
726
|
+
);
|
|
727
|
+
if (inWindow.length === 0) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const baseMs = new Date(inWindow[0].timestamp).getTime();
|
|
731
|
+
const lastInWindow = inWindow.length - 1;
|
|
732
|
+
waypoint.forecasts = inWindow.filter((step, i) => {
|
|
733
|
+
// The horizon's last step always rides along: weatherAt clamps
|
|
734
|
+
// to it, so a dropped tail would freeze the sim on stale values
|
|
735
|
+
if (i === lastInWindow) {
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
const hours = Math.round(
|
|
739
|
+
(new Date(step.timestamp).getTime() - baseMs) / HOUR_MS,
|
|
740
|
+
);
|
|
741
|
+
return hours < THROTTLE_HOURLY_HOURS || hours % 3 === 0;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return payloadWaypoints;
|
|
745
|
+
}
|
|
746
|
+
|
|
629
747
|
/**
|
|
630
748
|
* Cache directory for a data dir.
|
|
631
749
|
*
|
|
@@ -767,6 +885,7 @@ module.exports = {
|
|
|
767
885
|
normalizeLocations,
|
|
768
886
|
buildTimeSteps,
|
|
769
887
|
fetchWeatherAlongTrack,
|
|
888
|
+
throttleWaypointForecasts,
|
|
770
889
|
savePayload,
|
|
771
890
|
loadPayload,
|
|
772
891
|
listCachedRoutes,
|
package/plugin/index.js
CHANGED
|
@@ -2873,6 +2873,25 @@ module.exports = (app) => {
|
|
|
2873
2873
|
// the notification returns to nominal for every connected
|
|
2874
2874
|
// client, and the escalation does not re-raise the event until
|
|
2875
2875
|
// it leaves the matrix and returns
|
|
2876
|
+
// Active critical-hazard notifications (work doc #30): the
|
|
2877
|
+
// same list the escalation publishes over the Signal K stream,
|
|
2878
|
+
// served here so the webapp banner doesn't have to poll the
|
|
2879
|
+
// REST notifications subtree (absent path = noisy 404)
|
|
2880
|
+
router.get("/api/hazards/active", (_req, res) => {
|
|
2881
|
+
const notifications = [];
|
|
2882
|
+
for (const [id, notification] of activeHazardNotifications) {
|
|
2883
|
+
if (acknowledgedHazards.has(id)) {
|
|
2884
|
+
continue; // Acked: suppressed until the event returns
|
|
2885
|
+
}
|
|
2886
|
+
notifications.push({
|
|
2887
|
+
id,
|
|
2888
|
+
state: notification.state,
|
|
2889
|
+
message: notification.message,
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
res.json({ notifications });
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2876
2895
|
router.post("/api/hazards/ack", async (req, res) => {
|
|
2877
2896
|
const id = typeof req.body?.id === "string" ? req.body.id : null;
|
|
2878
2897
|
if (!id) {
|
package/plugin/weather-source.js
CHANGED
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
* @file weather-source.js
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
const {
|
|
34
|
+
const {
|
|
35
|
+
fetchWeatherAlongTrack,
|
|
36
|
+
throttleWaypointForecasts,
|
|
37
|
+
} = require("./fetch-engine.js");
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Meters per second to knots.
|
|
@@ -177,6 +180,7 @@ function buildApiPayload(waypoints, forecastsByWaypoint) {
|
|
|
177
180
|
};
|
|
178
181
|
});
|
|
179
182
|
const fetchedAt = new Date().toISOString();
|
|
183
|
+
throttleWaypointForecasts(payloadWaypoints, fetchedAt);
|
|
180
184
|
return {
|
|
181
185
|
metadata: {
|
|
182
186
|
fetchedAt,
|
|
@@ -515,13 +515,13 @@ class PassageOutlook extends HTMLElement {
|
|
|
515
515
|
// The next scheduled compile window rides /api/status (the same
|
|
516
516
|
// state-machine field the checklist reads); its absence degrades
|
|
517
517
|
// the Next pill to hidden, never to an invented stamp
|
|
518
|
-
this._nextCron = await fetchJson(`${PLUGIN_API}/
|
|
518
|
+
this._nextCron = await fetchJson(`${PLUGIN_API}/status`, 8000)
|
|
519
519
|
.then((status) => status?.nextCronRun ?? null)
|
|
520
520
|
.catch(() => null);
|
|
521
521
|
// Sea legs (work doc #40): the passage day context. Absent or
|
|
522
522
|
// failed lookups degrade to null — the timeline checkpoints
|
|
523
523
|
// simply don't render, never invented
|
|
524
|
-
this._seaLegs = await fetchJson(`${PLUGIN_API}/
|
|
524
|
+
this._seaLegs = await fetchJson(`${PLUGIN_API}/sea-legs`, 8000)
|
|
525
525
|
.then((s) => s?.passageStartedAt ?? null)
|
|
526
526
|
.catch(() => null);
|
|
527
527
|
this._briefing = await fetchJson(`${PLUGIN_API}/briefing${query}`, 20000);
|
|
@@ -830,6 +830,14 @@ class PassageOutlook extends HTMLElement {
|
|
|
830
830
|
? "<strategic-outlook></strategic-outlook>"
|
|
831
831
|
: "<tactical-dashboard></tactical-dashboard>";
|
|
832
832
|
this._applyModeToView();
|
|
833
|
+
// The tactical card head carries the route's Update now — the
|
|
834
|
+
// same affordance conditions-here has in its card head; disabled
|
|
835
|
+
// offline like the here button (the server refuses the refresh)
|
|
836
|
+
const tactical = this._view.querySelector("tactical-dashboard");
|
|
837
|
+
tactical?.setOnline(this._status?.online === true);
|
|
838
|
+
if (tactical) {
|
|
839
|
+
tactical.onRefresh = () => this.refreshBriefing();
|
|
840
|
+
}
|
|
833
841
|
if (this._exceptions) {
|
|
834
842
|
this.renderData();
|
|
835
843
|
}
|
|
@@ -287,38 +287,33 @@ export async function fetchNavigationState() {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
/**
|
|
290
|
-
* The plugin's critical-hazard notifications
|
|
291
|
-
*
|
|
292
|
-
*
|
|
290
|
+
* The plugin's active critical-hazard notifications (work doc #30),
|
|
291
|
+
* from the plugin's own `api/hazards/active` endpoint — the same list
|
|
292
|
+
* the escalation publishes over the Signal K stream. Polling the REST
|
|
293
|
+
* notifications subtree instead 404'd whenever nothing was active.
|
|
293
294
|
*
|
|
294
295
|
* @returns {Promise<Array<{id: string, state: string, message: string}>|
|
|
295
|
-
* null>} Active notifications
|
|
296
|
-
* path or server is unavailable
|
|
296
|
+
* null>} Active notifications, null when the server is unreachable
|
|
297
297
|
*/
|
|
298
298
|
export async function fetchHazardNotifications() {
|
|
299
|
-
const unwrap = (node) =>
|
|
300
|
-
node && typeof node === "object" && "value" in node ? node.value : node;
|
|
301
299
|
try {
|
|
302
|
-
const
|
|
303
|
-
|
|
300
|
+
const body = await fetchJson(
|
|
301
|
+
"/plugins/signalk-passage-briefing/api/hazards/active",
|
|
304
302
|
);
|
|
305
|
-
|
|
306
|
-
|
|
303
|
+
if (
|
|
304
|
+
!body ||
|
|
305
|
+
typeof body !== "object" ||
|
|
306
|
+
!Array.isArray(body.notifications)
|
|
307
|
+
) {
|
|
307
308
|
return null;
|
|
308
309
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
id,
|
|
317
|
-
state: String(value.state ?? "warn"),
|
|
318
|
-
message: String(value.message ?? ""),
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
return notifications;
|
|
310
|
+
return body.notifications
|
|
311
|
+
.filter((n) => n && typeof n === "object" && n.state !== "normal")
|
|
312
|
+
.map((n) => ({
|
|
313
|
+
id: String(n.id),
|
|
314
|
+
state: String(n.state ?? "warn"),
|
|
315
|
+
message: String(n.message ?? ""),
|
|
316
|
+
}));
|
|
322
317
|
} catch {
|
|
323
318
|
return null;
|
|
324
319
|
}
|
|
@@ -99,9 +99,21 @@ strong.sev {
|
|
|
99
99
|
text-overflow: ellipsis;
|
|
100
100
|
}
|
|
101
101
|
.sky span { margin-right: 10px; }
|
|
102
|
+
.card-head {
|
|
103
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
104
|
+
gap: 8px;
|
|
105
|
+
}
|
|
106
|
+
.card-head h2 { margin: 0; }
|
|
107
|
+
.refresh {
|
|
108
|
+
min-height: 36px; padding: 6px 12px; flex: none;
|
|
109
|
+
}
|
|
110
|
+
.refresh:disabled { opacity: 0.5; }
|
|
102
111
|
</style>
|
|
103
112
|
<section class="sk-card theme-teal">
|
|
104
|
-
<
|
|
113
|
+
<div class="card-head">
|
|
114
|
+
<h2>Next 24 Hours</h2>
|
|
115
|
+
<button id="refresh" class="refresh">Update now</button>
|
|
116
|
+
</div>
|
|
105
117
|
<div class="now">
|
|
106
118
|
<span class="label">AWS</span>
|
|
107
119
|
<span class="value" id="aws">—</span>
|
|
@@ -128,6 +140,9 @@ strong.sev {
|
|
|
128
140
|
this._awsEl = this.shadowRoot.getElementById("aws");
|
|
129
141
|
this._tierEl = this.shadowRoot.getElementById("tier");
|
|
130
142
|
this._cinfo = this.shadowRoot.getElementById("cinfo");
|
|
143
|
+
this._refreshEl = this.shadowRoot.getElementById("refresh");
|
|
144
|
+
this._refreshEl.disabled = !this._online;
|
|
145
|
+
this._refreshEl.addEventListener("click", () => this.onRefresh?.());
|
|
131
146
|
this._spark = this.shadowRoot.getElementById("spark");
|
|
132
147
|
this._departureControl =
|
|
133
148
|
this.shadowRoot.getElementById("departure-control");
|
|
@@ -230,6 +245,27 @@ strong.sev {
|
|
|
230
245
|
*/
|
|
231
246
|
onDepartureChange = null;
|
|
232
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Online state: the refresh button only fires while the link is up
|
|
250
|
+
* (the server refuses offline refreshes).
|
|
251
|
+
*
|
|
252
|
+
* @param {boolean} online
|
|
253
|
+
*/
|
|
254
|
+
setOnline(online) {
|
|
255
|
+
this._online = online === true;
|
|
256
|
+
if (this._refreshEl) {
|
|
257
|
+
this._refreshEl.disabled = !this._online;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refresh callback (set by the root component): posts the route
|
|
263
|
+
* refresh and reloads — the tactical view otherwise has no way to
|
|
264
|
+
* recompile between scheduled windows, and a cached payload
|
|
265
|
+
* predating a new payload field would render stale forever.
|
|
266
|
+
*/
|
|
267
|
+
onRefresh = null;
|
|
268
|
+
|
|
233
269
|
/**
|
|
234
270
|
* Briefing payload (work docs #3, #17): carries the space events
|
|
235
271
|
* and zone transitions the timeline merges in alongside the
|
|
@@ -4,7 +4,11 @@ const { mkdtempSync, existsSync, readdirSync } = require("node:fs");
|
|
|
4
4
|
const { tmpdir } = require("node:os");
|
|
5
5
|
const { join } = require("node:path");
|
|
6
6
|
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
forecastFixture,
|
|
9
|
+
mockOpenMeteo,
|
|
10
|
+
TIMES,
|
|
11
|
+
} = require("./openmeteo-mock.js");
|
|
8
12
|
|
|
9
13
|
const {
|
|
10
14
|
KEEP_SNAPSHOTS,
|
|
@@ -14,6 +18,7 @@ const {
|
|
|
14
18
|
kIndex,
|
|
15
19
|
normalizeLocations,
|
|
16
20
|
fetchWeatherAlongTrack,
|
|
21
|
+
throttleWaypointForecasts,
|
|
17
22
|
savePayload,
|
|
18
23
|
loadPayload,
|
|
19
24
|
listCachedRoutes,
|
|
@@ -129,6 +134,101 @@ const WAYPOINTS = sampleRoutePoints([
|
|
|
129
134
|
[0, 1.5],
|
|
130
135
|
]);
|
|
131
136
|
|
|
137
|
+
describe("throttleWaypointForecasts", () => {
|
|
138
|
+
const HOUR = 3600000;
|
|
139
|
+
const FETCH = "2026-10-16T00:00:00.000Z";
|
|
140
|
+
|
|
141
|
+
/** Builds one hourly step. */
|
|
142
|
+
const step = (h) => ({
|
|
143
|
+
timestamp: new Date(Date.parse(FETCH) + h * HOUR).toISOString(),
|
|
144
|
+
surface: { tws: h },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("windows bracket the arrival across the speed range", () => {
|
|
148
|
+
const waypoints = [
|
|
149
|
+
{
|
|
150
|
+
distanceFromStartNm: 0,
|
|
151
|
+
forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
distanceFromStartNm: 210,
|
|
155
|
+
forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
throttleWaypointForecasts(waypoints, FETCH);
|
|
159
|
+
const second = waypoints[1].forecasts.map((f) =>
|
|
160
|
+
Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
|
|
161
|
+
);
|
|
162
|
+
// Fast boat: 210 nm at 10 kn arrives at 21 h, window opens 12 h
|
|
163
|
+
// before — the early hours the boat cannot be there yet are gone
|
|
164
|
+
assert.equal(second[0], 9);
|
|
165
|
+
// Slow boat: 210 nm at 2.5 kn arrives at 84 h, window closes 48 h
|
|
166
|
+
// after — beyond that the boat is reading the next waypoint's data
|
|
167
|
+
assert.equal(second[second.length - 1], 132);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("hourly steps collapse to 3-hourly past the first day of the window", () => {
|
|
171
|
+
const waypoints = [
|
|
172
|
+
{
|
|
173
|
+
distanceFromStartNm: 0,
|
|
174
|
+
forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
throttleWaypointForecasts(waypoints, FETCH);
|
|
178
|
+
const stamps = waypoints[0].forecasts.map((f) =>
|
|
179
|
+
Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
|
|
180
|
+
);
|
|
181
|
+
// The departure waypoint's window closes 48 h after its
|
|
182
|
+
// (immediate) arrival: 24 hourly steps, then every third hour
|
|
183
|
+
assert.equal(stamps.length, 24 + (48 - 24) / 3 + 1);
|
|
184
|
+
assert.equal(stamps[stamps.length - 1], 48);
|
|
185
|
+
for (let i = 24; i < stamps.length - 1; i++) {
|
|
186
|
+
assert.equal(stamps[i] % 3, 0);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a guaranteed minimum span survives a wildly slow boat", () => {
|
|
191
|
+
// 420 nm: the slow-bracket arrival (2.5 kn) sits past the 168 h
|
|
192
|
+
// horizon, so the window rides the whole horizon's tail — plenty
|
|
193
|
+
// of coverage either way, and the last step must survive decimation
|
|
194
|
+
const waypoints = [
|
|
195
|
+
{
|
|
196
|
+
distanceFromStartNm: 420,
|
|
197
|
+
forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
throttleWaypointForecasts(waypoints, FETCH);
|
|
201
|
+
const stamps = waypoints[0].forecasts.map((f) =>
|
|
202
|
+
Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
|
|
203
|
+
);
|
|
204
|
+
assert.ok(stamps.length >= 16, "at least two days' 3-hourly coverage");
|
|
205
|
+
assert.equal(stamps[stamps.length - 1], 167);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("short fixtures pass through unchanged", () => {
|
|
209
|
+
const forecasts = [step(0), step(1)];
|
|
210
|
+
const waypoints = [{ distanceFromStartNm: 0, forecasts }];
|
|
211
|
+
throttleWaypointForecasts(waypoints, FETCH);
|
|
212
|
+
assert.equal(waypoints[0].forecasts.length, 2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("roughly a quarter of the original payload remains on a week-long route", () => {
|
|
216
|
+
// 15 waypoints, 30 nm apart, 7-day horizon: the shape behind the
|
|
217
|
+
// 1.3 MB briefing
|
|
218
|
+
const waypoints = Array.from({ length: 15 }, (_, i) => ({
|
|
219
|
+
distanceFromStartNm: i * 30,
|
|
220
|
+
forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
|
|
221
|
+
}));
|
|
222
|
+
const before = waypoints.reduce((sum, wp) => sum + wp.forecasts.length, 0);
|
|
223
|
+
throttleWaypointForecasts(waypoints, FETCH);
|
|
224
|
+
const after = waypoints.reduce((sum, wp) => sum + wp.forecasts.length, 0);
|
|
225
|
+
assert.ok(after <= before * 0.35, `${after} of ${before} steps remain`);
|
|
226
|
+
for (const wp of waypoints) {
|
|
227
|
+
assert.ok(wp.forecasts.length > 0, "no waypoint stripped bare");
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
132
232
|
describe("fetchWeatherAlongTrack", () => {
|
|
133
233
|
test("builds the SPEC §3.1 payload with unit conversions", async () => {
|
|
134
234
|
const payload = await fetchWeatherAlongTrack({
|
|
@@ -202,6 +302,37 @@ describe("fetchWeatherAlongTrack", () => {
|
|
|
202
302
|
);
|
|
203
303
|
});
|
|
204
304
|
|
|
305
|
+
test("falls back to mean wave period when the model publishes no peak", async () => {
|
|
306
|
+
// GFS-Wave on Open-Meteo returns wave_peak_period as all-null
|
|
307
|
+
// (verified October 2026) — the comfort model needs a period, so
|
|
308
|
+
// the mean-period series steps in
|
|
309
|
+
const noPeak = {
|
|
310
|
+
waves: {
|
|
311
|
+
hourly: {
|
|
312
|
+
time: TIMES,
|
|
313
|
+
wave_height: [1.2, 1.4],
|
|
314
|
+
wave_direction: [160, 165],
|
|
315
|
+
wave_peak_period: [null, null],
|
|
316
|
+
wave_period: [6.9, 7.2],
|
|
317
|
+
wind_wave_height: [0.5, 0.6],
|
|
318
|
+
wind_wave_period: [4, 4.2],
|
|
319
|
+
wind_wave_direction: [170, 172],
|
|
320
|
+
swell_wave_height: [0.9, 1.0],
|
|
321
|
+
swell_wave_period: [9, 9.1],
|
|
322
|
+
swell_wave_direction: [150, 152],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
const payload = await fetchWeatherAlongTrack({
|
|
327
|
+
waypoints: WAYPOINTS,
|
|
328
|
+
fetchImpl: mockOpenMeteo(noPeak),
|
|
329
|
+
});
|
|
330
|
+
assert.equal(payload.waypoints[0].forecasts[0].marine.tpCombined, 6.9);
|
|
331
|
+
assert.equal(payload.waypoints[0].forecasts[1].marine.tpCombined, 7.2);
|
|
332
|
+
// hsCombined unaffected
|
|
333
|
+
assert.equal(payload.waypoints[0].forecasts[0].marine.hsCombined, 1.2);
|
|
334
|
+
});
|
|
335
|
+
|
|
205
336
|
test("aligns multi-location responses in request order", async () => {
|
|
206
337
|
const waypoints = sampleRoutePoints([
|
|
207
338
|
[0, 0],
|
package/tests/openmeteo-mock.js
CHANGED
|
@@ -45,6 +45,7 @@ function wavesFixture() {
|
|
|
45
45
|
wave_height: [1.2, 1.4],
|
|
46
46
|
wave_direction: [160, 165],
|
|
47
47
|
wave_peak_period: [7, 7.5],
|
|
48
|
+
wave_period: [6.9, 7.2],
|
|
48
49
|
wind_wave_height: [0.5, 0.6],
|
|
49
50
|
wind_wave_period: [4, 4.2],
|
|
50
51
|
wind_wave_direction: [170, 172],
|
|
@@ -96,6 +97,7 @@ function mockOpenMeteo(overrides = {}) {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
module.exports = {
|
|
100
|
+
TIMES,
|
|
99
101
|
forecastFixture,
|
|
100
102
|
wavesFixture,
|
|
101
103
|
currentsFixture,
|
package/tests/plugin.test.js
CHANGED
|
@@ -2687,3 +2687,31 @@ test("routes listing: cached with stale-while-revalidate, distances active-only"
|
|
|
2687
2687
|
assert.equal(listCalls, 1);
|
|
2688
2688
|
plugin.stop();
|
|
2689
2689
|
});
|
|
2690
|
+
|
|
2691
|
+
test("active hazard notifications serve from the plugin's own endpoint", async () => {
|
|
2692
|
+
// Work doc #30 webapp transport: the escalation's active list rides
|
|
2693
|
+
// GET /api/hazards/active (200 with an empty list when nothing is
|
|
2694
|
+
// active) — polling the Signal K REST notifications subtree instead
|
|
2695
|
+
// 404'd whenever no notification had ever been raised
|
|
2696
|
+
const app = createMockApp();
|
|
2697
|
+
const plugin = pluginFactory(app);
|
|
2698
|
+
plugin.start({});
|
|
2699
|
+
plugin.registerWithRouter(app.router);
|
|
2700
|
+
const route = app.getRoutes().find((r) => r.path === "/api/hazards/active");
|
|
2701
|
+
assert.ok(route, "hazards/active route registered");
|
|
2702
|
+
const res = {
|
|
2703
|
+
code: null,
|
|
2704
|
+
payload: null,
|
|
2705
|
+
status(code) {
|
|
2706
|
+
this.code = code;
|
|
2707
|
+
return this;
|
|
2708
|
+
},
|
|
2709
|
+
json(payload) {
|
|
2710
|
+
this.payload = payload;
|
|
2711
|
+
},
|
|
2712
|
+
};
|
|
2713
|
+
await route.handler({}, res);
|
|
2714
|
+
assert.equal(res.code, null);
|
|
2715
|
+
assert.deepEqual(res.payload, { notifications: [] });
|
|
2716
|
+
plugin.stop();
|
|
2717
|
+
});
|