@meri-imperiumi/signalk-passage-briefing 0.2.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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Multi-endpoint weather & METAREA aggregation (SPEC §3.1).
3
+ *
4
+ * Builds the UnifiedWeatherPayload along a route's waypoints from
5
+ * Open-Meteo, the same source SV Sabado's passage-weather vetted
6
+ * against raw ECMWF/NOAA GRIB:
7
+ *
8
+ * - forecast API: surface wind (kn), gusts, MSL pressure, CAPE, and
9
+ * the pressure-layer fields behind the K-index (computed here from
10
+ * T850/T700/T500 and dewpoints via Magnus);
11
+ * - marine API (`ncep_gfswave025`): combined sea plus wind sea and
12
+ * swell partitions (the SPEC §3.1 marine block needs the split;
13
+ * ECMWF's split fields come back empty per passage-weather's
14
+ * 2026-09 checks);
15
+ * - marine API currents: Météo-France SMOC surface current.
16
+ *
17
+ * The marine and current endpoints degrade gracefully — a failure
18
+ * nulls their fields — while a forecast failure fails the fetch.
19
+ *
20
+ * Everything fetched is cached to the plugin data directory
21
+ * (`weather/latest-<route>.json` plus dated snapshots, newest kept):
22
+ * on passage the boat is online for about an hour per day, so the
23
+ * last payload must survive restarts and serve offline reads.
24
+ *
25
+ * Conventions: wind directions are the direction the wind blows FROM
26
+ * (degrees true, matching Signal K), waves likewise FROM; speeds in
27
+ * knots; pressure hPa; timestamps ISO UTC.
28
+ *
29
+ * @file fetch-engine.js
30
+ */
31
+
32
+ const {
33
+ mkdir,
34
+ readFile,
35
+ readdir,
36
+ rename,
37
+ writeFile,
38
+ } = require("node:fs/promises");
39
+ const path = require("node:path");
40
+
41
+ /**
42
+ * Earth radius in nautical miles.
43
+ */
44
+ const EARTH_RADIUS_NM = 3440.065;
45
+
46
+ /**
47
+ * Default route sampling interval.
48
+ */
49
+ const DEFAULT_SAMPLE_INTERVAL_NM = 30;
50
+
51
+ /**
52
+ * Default forecast horizon.
53
+ */
54
+ const DEFAULT_FORECAST_DAYS = 7;
55
+
56
+ /**
57
+ * Snapshots kept per route in the cache.
58
+ */
59
+ const KEEP_SNAPSHOTS = 8;
60
+
61
+ const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
62
+ const MARINE_URL = "https://marine-api.open-meteo.com/v1/marine";
63
+
64
+ const FORECAST_HOURLY = [
65
+ "wind_speed_10m",
66
+ "wind_direction_10m",
67
+ "wind_gusts_10m",
68
+ "pressure_msl",
69
+ "cape",
70
+ "temperature_850hPa",
71
+ "temperature_700hPa",
72
+ "temperature_500hPa",
73
+ "relative_humidity_850hPa",
74
+ "relative_humidity_700hPa",
75
+ "wind_speed_850hPa",
76
+ ].join(",");
77
+
78
+ const WAVE_HOURLY = [
79
+ "wave_height",
80
+ "wave_direction",
81
+ "wave_peak_period",
82
+ "wind_wave_height",
83
+ "wind_wave_period",
84
+ "wind_wave_direction",
85
+ "swell_wave_height",
86
+ "swell_wave_period",
87
+ "swell_wave_direction",
88
+ ].join(",");
89
+
90
+ const CURRENT_HOURLY = [
91
+ "ocean_current_velocity",
92
+ "ocean_current_direction",
93
+ ].join(",");
94
+
95
+ /**
96
+ * Parses a timestamp that carries no explicit offset as UTC
97
+ * (Open-Meteo returns naive ISO strings under `timezone=GMT`).
98
+ *
99
+ * @param {string} time
100
+ * @returns {Date}
101
+ */
102
+ function parseUtcTimestamp(time) {
103
+ const hasOffset = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(time);
104
+ return new Date(hasOffset ? time : `${time}Z`);
105
+ }
106
+
107
+ /**
108
+ * Great-circle distance between two positions.
109
+ *
110
+ * @param {number} lat1 - Degrees
111
+ * @param {number} lon1 - Degrees
112
+ * @param {number} lat2 - Degrees
113
+ * @param {number} lon2 - Degrees
114
+ * @returns {number} Distance in nautical miles
115
+ */
116
+ function distanceNm(lat1, lon1, lat2, lon2) {
117
+ const toRad = Math.PI / 180;
118
+ const φ1 = lat1 * toRad;
119
+ const φ2 = lat2 * toRad;
120
+ const dφ = (lat2 - lat1) * toRad;
121
+ const dλ = (lon2 - lon1) * toRad;
122
+ const a =
123
+ Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
124
+ return 2 * EARTH_RADIUS_NM * Math.asin(Math.min(1, Math.sqrt(a)));
125
+ }
126
+
127
+ /**
128
+ * Great-circle interpolation between two positions (spherical linear
129
+ * interpolation), fraction `f` along the great circle.
130
+ *
131
+ * @param {number} lat1 - Degrees
132
+ * @param {number} lon1 - Degrees
133
+ * @param {number} lat2 - Degrees
134
+ * @param {number} lon2 - Degrees
135
+ * @param {number} f - Fraction 0..1
136
+ * @returns {{lat: number, lon: number}}
137
+ */
138
+ function interpolatePosition(lat1, lon1, lat2, lon2, f) {
139
+ const toRad = Math.PI / 180;
140
+ const toDeg = 180 / Math.PI;
141
+ const φ1 = lat1 * toRad;
142
+ const λ1 = lon1 * toRad;
143
+ const φ2 = lat2 * toRad;
144
+ const λ2 = lon2 * toRad;
145
+ const d =
146
+ 2 *
147
+ Math.asin(
148
+ Math.min(
149
+ 1,
150
+ Math.sqrt(
151
+ Math.sin((φ2 - φ1) / 2) ** 2 +
152
+ Math.cos(φ1) * Math.cos(φ2) * Math.sin((λ2 - λ1) / 2) ** 2,
153
+ ),
154
+ ),
155
+ );
156
+ if (d < 1e-9) {
157
+ return { lat: lat1, lon: lon1 };
158
+ }
159
+ const A = Math.sin((1 - f) * d) / Math.sin(d);
160
+ const B = Math.sin(f * d) / Math.sin(d);
161
+ const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
162
+ const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
163
+ const z = A * Math.sin(φ1) + B * Math.sin(φ2);
164
+ return {
165
+ lat: Math.atan2(z, Math.sqrt(x * x + y * y)) * toDeg,
166
+ lon: Math.atan2(y, x) * toDeg,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Samples a route geometry into waypoints at most `intervalNm` apart
172
+ * (passage-weather's scheme: the route is divided into
173
+ * `ceil(total/interval)` even steps, so spacing is uniform and both
174
+ * ends are included), recording each waypoint's distance from the
175
+ * start (SPEC §3.1 waypoints shape).
176
+ *
177
+ * @param {number[][]} coordinates - Route GeoJSON coordinates
178
+ * ([[lon, lat], ...], Signal K convention)
179
+ * @param {number} [intervalNm] - Maximum sampling interval
180
+ * @returns {Array<{lat: number, lon: number, distanceFromStartNm: number}>}
181
+ */
182
+ function sampleRoutePoints(
183
+ coordinates,
184
+ intervalNm = DEFAULT_SAMPLE_INTERVAL_NM,
185
+ ) {
186
+ if (!Array.isArray(coordinates) || coordinates.length === 0) {
187
+ return [];
188
+ }
189
+ if (coordinates.length === 1) {
190
+ return [
191
+ {
192
+ lat: coordinates[0][1],
193
+ lon: coordinates[0][0],
194
+ distanceFromStartNm: 0,
195
+ },
196
+ ];
197
+ }
198
+ const legs = [];
199
+ let total = 0;
200
+ for (let i = 1; i < coordinates.length; i++) {
201
+ const leg = distanceNm(
202
+ coordinates[i - 1][1],
203
+ coordinates[i - 1][0],
204
+ coordinates[i][1],
205
+ coordinates[i][0],
206
+ );
207
+ legs.push({
208
+ lat1: coordinates[i - 1][1],
209
+ lon1: coordinates[i - 1][0],
210
+ lat2: coordinates[i][1],
211
+ lon2: coordinates[i][0],
212
+ start: total,
213
+ length: leg,
214
+ });
215
+ total += leg;
216
+ }
217
+ const steps = Math.max(1, Math.ceil(total / intervalNm));
218
+ const stepLength = total / steps;
219
+ const waypoints = [];
220
+ for (let i = 0; i <= steps; i++) {
221
+ const d = i * stepLength;
222
+ const pos = positionAt(legs, d);
223
+ waypoints.push({
224
+ lat: pos.lat,
225
+ lon: pos.lon,
226
+ distanceFromStartNm: Math.round(d * 10) / 10,
227
+ });
228
+ }
229
+ return waypoints;
230
+ }
231
+
232
+ /**
233
+ * Position at distance `d` along a leg list.
234
+ *
235
+ * @param {Array<{lat1: number, lon1: number, lat2: number, lon2: number, start: number, length: number}>} legs
236
+ * @param {number} d - Distance from the route start (nm)
237
+ * @returns {{lat: number, lon: number}}
238
+ */
239
+ function positionAt(legs, d) {
240
+ for (let i = 0; i < legs.length; i++) {
241
+ const leg = legs[i];
242
+ if (d <= leg.start + leg.length || i === legs.length - 1) {
243
+ const f = leg.length < 1e-9 ? 0 : (d - leg.start) / leg.length;
244
+ return interpolatePosition(leg.lat1, leg.lon1, leg.lat2, leg.lon2, f);
245
+ }
246
+ }
247
+ const last = legs[legs.length - 1];
248
+ return { lat: last.lat2, lon: last.lon2 };
249
+ }
250
+
251
+ /**
252
+ * Total length of a route geometry.
253
+ *
254
+ * @param {number[][]} coordinates - [[lon, lat], ...]
255
+ * @returns {number} Distance in nautical miles
256
+ */
257
+ function routeDistanceNm(coordinates) {
258
+ let total = 0;
259
+ for (let i = 1; i < (coordinates?.length ?? 0); i++) {
260
+ total += distanceNm(
261
+ coordinates[i - 1][1],
262
+ coordinates[i - 1][0],
263
+ coordinates[i][1],
264
+ coordinates[i][0],
265
+ );
266
+ }
267
+ return Math.round(total * 10) / 10;
268
+ }
269
+
270
+ /**
271
+ * Dew point from temperature and relative humidity (Magnus formula,
272
+ * a=17.62 b=243.12 — accurate enough for the K-index).
273
+ *
274
+ * @param {number} tempC
275
+ * @param {number} rhPercent
276
+ * @returns {number} Dew point (°C)
277
+ */
278
+ function dewpointC(tempC, rhPercent) {
279
+ const a = 17.62;
280
+ const b = 243.12;
281
+ const gamma =
282
+ Math.log(Math.max(rhPercent, 1) / 100) + (a * tempC) / (b + tempC);
283
+ return (b * gamma) / (a - gamma);
284
+ }
285
+
286
+ /**
287
+ * K-index from pressure-layer temperatures and humidities:
288
+ * K = (T850 − T500) + Td850 − (T700 − Td700). Thunderstorm potential
289
+ * grows above ~28 (SPEC §6.2 convective warnings use 28).
290
+ *
291
+ * @param {object} levels
292
+ * @param {number} levels.t850 - °C
293
+ * @param {number} levels.t700 - °C
294
+ * @param {number} levels.t500 - °C
295
+ * @param {number} levels.rh850 - %
296
+ * @param {number} levels.rh700 - %
297
+ * @returns {number|null} K-index, null on missing inputs
298
+ */
299
+ function kIndex({ t850, t700, t500, rh850, rh700 }) {
300
+ if (
301
+ [t850, t700, t500, rh850, rh700].some(
302
+ (v) => typeof v !== "number" || !Number.isFinite(v),
303
+ )
304
+ ) {
305
+ return null;
306
+ }
307
+ return t850 - t500 + dewpointC(t850, rh850) - (t700 - dewpointC(t700, rh700));
308
+ }
309
+
310
+ /**
311
+ * Fetch helper with per-attempt timeout and retry with backoff on
312
+ * 429/5xx/network errors (an uplink that blackholes or rate-limits
313
+ * must not wedge a fetch window).
314
+ *
315
+ * @param {string} url
316
+ * @param {typeof fetch} fetchImpl
317
+ * @param {number} timeoutMs
318
+ * @param {number} [tries=3]
319
+ * @returns {Promise<object>} Parsed JSON
320
+ */
321
+ async function fetchJson(url, fetchImpl, timeoutMs, tries = 3) {
322
+ let lastError;
323
+ // Non-ok bodies often carry the actionable part (Open-Meteo 400s
324
+ // carry a `reason`); surface a snippet in the error so the plugin
325
+ // status says WHY, not just which URL
326
+ const describe = async (response) => {
327
+ let detail = "";
328
+ try {
329
+ const body = await response.text();
330
+ const parsed = JSON.parse(body);
331
+ detail = parsed.reason ?? parsed.message ?? body;
332
+ } catch {
333
+ detail = "";
334
+ }
335
+ detail = String(detail).slice(0, 200);
336
+ return `${url} returned ${response.status}${detail ? `: ${detail}` : ""}`;
337
+ };
338
+ for (let attempt = 0; attempt < tries; attempt++) {
339
+ const controller = new AbortController();
340
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
341
+ try {
342
+ const response = await fetchImpl(url, { signal: controller.signal });
343
+ if (response.status === 429 || response.status >= 500) {
344
+ lastError = new Error(await describe(response));
345
+ } else if (!response.ok) {
346
+ throw new Error(await describe(response));
347
+ } else {
348
+ return await response.json();
349
+ }
350
+ } catch (error) {
351
+ lastError = error;
352
+ } finally {
353
+ clearTimeout(timer);
354
+ }
355
+ await new Promise((resolve) => setTimeout(resolve, 2000 * (attempt + 1)));
356
+ }
357
+ throw lastError;
358
+ }
359
+
360
+ /**
361
+ * Normalizes an Open-Meteo response covering one location per
362
+ * requested coordinate (a single object for one location, an array
363
+ * in request order for several) into an array aligned with the
364
+ * request.
365
+ *
366
+ * @param {object|object[]} response
367
+ * @param {number} count - Number of requested locations
368
+ * @returns {Array<object|null>}
369
+ */
370
+ function normalizeLocations(response, count) {
371
+ if (response == null) {
372
+ return Array(count).fill(null);
373
+ }
374
+ const blocks = Array.isArray(response) ? response : [response];
375
+ return Array.from({ length: count }, (_, i) => blocks[i] ?? null);
376
+ }
377
+
378
+ /**
379
+ * Aligns one location's hourly arrays into a time → value lookup.
380
+ *
381
+ * @param {object|null} block - Open-Meteo location block
382
+ * @param {string[]} keys - Hourly variable names
383
+ * @returns {Map<string, Object<string, number|null>>}
384
+ */
385
+ function hourlyLookup(block, keys) {
386
+ const map = new Map();
387
+ if (!block?.hourly?.time) {
388
+ return map;
389
+ }
390
+ const { time } = block.hourly;
391
+ for (let i = 0; i < time.length; i++) {
392
+ const row = {};
393
+ for (const key of keys) {
394
+ const values = block.hourly[key];
395
+ row[key] = values ? (values[i] ?? null) : null;
396
+ }
397
+ map.set(parseUtcTimestamp(time[i]).toISOString(), row);
398
+ }
399
+ return map;
400
+ }
401
+
402
+ /**
403
+ * Number-like passthrough (Open-Meteo uses null for missing hours).
404
+ *
405
+ * @param {unknown} value
406
+ * @returns {number|null}
407
+ */
408
+ function num(value) {
409
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
410
+ }
411
+
412
+ /**
413
+ * Builds the SPEC §3.1 TimeStepForecast for one waypoint by merging
414
+ * the aligned forecast, wave and current lookups.
415
+ *
416
+ * @param {Map<string, object>} surfaceMap
417
+ * @param {Map<string, object>} marineMap
418
+ * @param {Map<string, object>} currentMap
419
+ * @returns {Array<object>} TimeStepForecast list
420
+ */
421
+ function buildTimeSteps(surfaceMap, marineMap, currentMap) {
422
+ const times = new Set([...surfaceMap.keys(), ...marineMap.keys()]);
423
+ const steps = [];
424
+ for (const time of [...times].sort()) {
425
+ const surface = surfaceMap.get(time) ?? {};
426
+ const marine = marineMap.get(time) ?? {};
427
+ const currents = currentMap.get(time) ?? {};
428
+ const gust = num(surface.wind_gusts_10m);
429
+ const kIndexValue = kIndex({
430
+ t850: num(surface.temperature_850hPa),
431
+ t700: num(surface.temperature_700hPa),
432
+ t500: num(surface.temperature_500hPa),
433
+ rh850: num(surface.relative_humidity_850hPa),
434
+ rh700: num(surface.relative_humidity_700hPa),
435
+ });
436
+ // Ocean current direction convention: SMOC reports the direction
437
+ // the water sets TOWARD; velocity arrives in km/h.
438
+ const driftKmh = num(currents.ocean_current_velocity);
439
+ steps.push({
440
+ timestamp: time,
441
+ surface: {
442
+ tws: num(surface.wind_speed_10m),
443
+ twd: num(surface.wind_direction_10m),
444
+ mslp: num(surface.pressure_msl),
445
+ gust,
446
+ },
447
+ marine: {
448
+ hsCombined: num(marine.wave_height),
449
+ tpCombined: num(marine.wave_peak_period),
450
+ dirCombined: num(marine.wave_direction),
451
+ hsSwell: num(marine.swell_wave_height),
452
+ tpSwell: num(marine.swell_wave_period),
453
+ dirSwell: num(marine.swell_wave_direction),
454
+ hsWindSea: num(marine.wind_wave_height),
455
+ tpWindSea: num(marine.wind_wave_period),
456
+ dirWindSea: num(marine.wind_wave_direction),
457
+ },
458
+ upperAir: {
459
+ cape: num(surface.cape),
460
+ kIndex: kIndexValue != null ? Math.round(kIndexValue * 10) / 10 : null,
461
+ rh700: num(surface.relative_humidity_700hPa),
462
+ wind850kts: num(surface.wind_speed_850hPa),
463
+ },
464
+ current: {
465
+ drift:
466
+ driftKmh != null ? Math.round((driftKmh / 1.852) * 100) / 100 : null,
467
+ set: num(currents.ocean_current_direction),
468
+ },
469
+ });
470
+ }
471
+ return steps;
472
+ }
473
+
474
+ /**
475
+ * Fetches weather along the route waypoints and assembles the
476
+ * UnifiedWeatherPayload (SPEC §3.1).
477
+ *
478
+ * @param {object} params
479
+ * @param {Array<{lat: number, lon: number, distanceFromStartNm: number}>} params.waypoints
480
+ * From {@link sampleRoutePoints}
481
+ * @param {number} [params.forecastDays] - Forecast horizon (default
482
+ * {@link DEFAULT_FORECAST_DAYS})
483
+ * @param {number} [params.timeoutMs] - Per-attempt timeout
484
+ * @param {typeof fetch} [params.fetchImpl] - Fetch implementation (tests)
485
+ * @returns {Promise<object>} UnifiedWeatherPayload
486
+ */
487
+ async function fetchWeatherAlongTrack({
488
+ waypoints,
489
+ forecastDays = DEFAULT_FORECAST_DAYS,
490
+ timeoutMs = 30000,
491
+ fetchImpl = fetch,
492
+ }) {
493
+ if (!Array.isArray(waypoints) || waypoints.length === 0) {
494
+ throw new Error("No waypoints to fetch weather for");
495
+ }
496
+ const lats = waypoints.map((w) => w.lat.toFixed(3)).join(",");
497
+ const lons = waypoints.map((w) => w.lon.toFixed(3)).join(",");
498
+
499
+ const forecastUrl =
500
+ `${FORECAST_URL}?latitude=${lats}&longitude=${lons}` +
501
+ `&hourly=${FORECAST_HOURLY}&forecast_days=${forecastDays}&timezone=GMT&wind_speed_unit=kn`;
502
+ const wavesUrl =
503
+ `${MARINE_URL}?latitude=${lats}&longitude=${lons}` +
504
+ `&hourly=${WAVE_HOURLY}&forecast_days=${forecastDays}&timezone=GMT&models=ncep_gfswave025`;
505
+ const currentsUrl =
506
+ `${MARINE_URL}?latitude=${lats}&longitude=${lons}` +
507
+ `&hourly=${CURRENT_HOURLY}&forecast_days=${forecastDays}&timezone=GMT`;
508
+
509
+ // The forecast is the core; sea and current degrade gracefully.
510
+ const forecastResponse = await fetchJson(forecastUrl, fetchImpl, timeoutMs);
511
+ const [wavesResponse, currentsResponse] = await Promise.all([
512
+ fetchJson(wavesUrl, fetchImpl, timeoutMs).catch(() => null),
513
+ fetchJson(currentsUrl, fetchImpl, timeoutMs).catch(() => null),
514
+ ]);
515
+
516
+ const forecastBlocks = normalizeLocations(forecastResponse, waypoints.length);
517
+ const waveBlocks = normalizeLocations(wavesResponse, waypoints.length);
518
+ const currentBlocks = normalizeLocations(currentsResponse, waypoints.length);
519
+
520
+ const payloadWaypoints = waypoints.map((waypoint, index) => {
521
+ const surfaceMap = hourlyLookup(forecastBlocks[index], [
522
+ "wind_speed_10m",
523
+ "wind_direction_10m",
524
+ "wind_gusts_10m",
525
+ "pressure_msl",
526
+ "cape",
527
+ "temperature_850hPa",
528
+ "temperature_700hPa",
529
+ "temperature_500hPa",
530
+ "relative_humidity_850hPa",
531
+ "relative_humidity_700hPa",
532
+ "wind_speed_850hPa",
533
+ ]);
534
+ const marineMap = hourlyLookup(waveBlocks[index], [
535
+ "wave_height",
536
+ "wave_direction",
537
+ "wave_peak_period",
538
+ "wind_wave_height",
539
+ "wind_wave_period",
540
+ "wind_wave_direction",
541
+ "swell_wave_height",
542
+ "swell_wave_period",
543
+ "swell_wave_direction",
544
+ ]);
545
+ const currentMap = hourlyLookup(currentBlocks[index], [
546
+ "ocean_current_velocity",
547
+ "ocean_current_direction",
548
+ ]);
549
+ return {
550
+ lat: waypoint.lat,
551
+ lon: waypoint.lon,
552
+ distanceFromStartNm: waypoint.distanceFromStartNm,
553
+ forecasts: buildTimeSteps(surfaceMap, marineMap, currentMap),
554
+ };
555
+ });
556
+
557
+ const models = ["forecast:best_match"];
558
+ if (wavesResponse != null) {
559
+ models.push("marine:ncep_gfswave025");
560
+ }
561
+ if (currentsResponse != null) {
562
+ models.push("marine:smoc_currents");
563
+ }
564
+ return {
565
+ metadata: {
566
+ fetchedAt: new Date().toISOString(),
567
+ source: "api",
568
+ models,
569
+ },
570
+ waypoints: payloadWaypoints,
571
+ };
572
+ }
573
+
574
+ /**
575
+ * Cache directory for a data dir.
576
+ *
577
+ * @param {string} dataDir
578
+ * @returns {string}
579
+ */
580
+ function cacheDir(dataDir) {
581
+ return path.join(dataDir, "weather");
582
+ }
583
+
584
+ /**
585
+ * Sanitizes a route id for use as a cache file name.
586
+ *
587
+ * @param {string} routeId
588
+ * @returns {string}
589
+ */
590
+ function routeFileId(routeId) {
591
+ return encodeURIComponent(String(routeId)).replace(/[.']/g, "_");
592
+ }
593
+
594
+ /**
595
+ * Persists a briefing payload: `latest-<route>.json` (atomic rename,
596
+ * always the one offline reads hit) plus a dated snapshot so the crew
597
+ * can see how forecasts evolve between fetch windows. Older
598
+ * snapshots beyond {@link KEEP_SNAPSHOTS} are pruned.
599
+ *
600
+ * @param {string} dataDir - Plugin data directory
601
+ * @param {string} routeId
602
+ * @param {object} payload - UnifiedWeatherPayload
603
+ * @returns {Promise<void>}
604
+ */
605
+ async function savePayload(dataDir, routeId, payload) {
606
+ const dir = cacheDir(dataDir);
607
+ await mkdir(dir, { recursive: true });
608
+ const fileId = routeFileId(routeId);
609
+ const stamp = String(payload?.metadata?.fetchedAt ?? Date.now()).replace(
610
+ /[:.]/g,
611
+ "-",
612
+ );
613
+ await writeFile(
614
+ path.join(dir, `latest-${fileId}.json`),
615
+ JSON.stringify(payload),
616
+ );
617
+ const tmp = path.join(dir, `.tmp-${fileId}-${stamp}.json`);
618
+ await writeFile(tmp, JSON.stringify(payload));
619
+ await rename(tmp, path.join(dir, `${fileId}-${stamp}.json`));
620
+
621
+ const files = (await readdir(dir))
622
+ .filter((f) => f.startsWith(`${fileId}-`) && f.endsWith(".json"))
623
+ .sort();
624
+ for (const old of files.slice(
625
+ 0,
626
+ Math.max(0, files.length - KEEP_SNAPSHOTS),
627
+ )) {
628
+ await rm(path.join(dir, old));
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Removes a cache file (fs.rm promise helper).
634
+ *
635
+ * @param {string} file
636
+ * @returns {Promise<void>}
637
+ */
638
+ function rm(file) {
639
+ const { unlink } = require("node:fs/promises");
640
+ return unlink(file).catch(() => {});
641
+ }
642
+
643
+ /**
644
+ * Loads the latest cached payload for a route.
645
+ *
646
+ * @param {string} dataDir - Plugin data directory
647
+ * @param {string} routeId
648
+ * @returns {Promise<object|null>} {payload, cachedAt} or null when
649
+ * nothing is cached
650
+ */
651
+ async function loadPayload(dataDir, routeId) {
652
+ try {
653
+ const file = path.join(
654
+ cacheDir(dataDir),
655
+ `latest-${routeFileId(routeId)}.json`,
656
+ );
657
+ const payload = JSON.parse(await readFile(file, "utf8"));
658
+ return { payload, cachedAt: payload?.metadata?.fetchedAt ?? null };
659
+ } catch (_error) {
660
+ return null;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Lists the routes that have a cached briefing.
666
+ *
667
+ * @param {string} dataDir - Plugin data directory
668
+ * @returns {Promise<Array<{routeId: string, cachedAt: string|null}>>}
669
+ */
670
+ async function listCachedRoutes(dataDir) {
671
+ let files = [];
672
+ try {
673
+ files = await readdir(cacheDir(dataDir));
674
+ } catch (_error) {
675
+ return [];
676
+ }
677
+ return files
678
+ .filter((f) => f.startsWith("latest-") && f.endsWith(".json"))
679
+ .map(async (f) => {
680
+ const routeId = decodeURIComponent(
681
+ f.slice("latest-".length, -".json".length),
682
+ );
683
+ try {
684
+ const payload = JSON.parse(
685
+ await readFile(path.join(cacheDir(dataDir), f), "utf8"),
686
+ );
687
+ return { routeId, cachedAt: payload?.metadata?.fetchedAt ?? null };
688
+ } catch (_error) {
689
+ return { routeId, cachedAt: null };
690
+ }
691
+ })
692
+ .reduce(
693
+ async (acc, promise) => [...(await acc), await promise],
694
+ Promise.resolve([]),
695
+ );
696
+ }
697
+
698
+ module.exports = {
699
+ DEFAULT_FORECAST_DAYS,
700
+ DEFAULT_SAMPLE_INTERVAL_NM,
701
+ KEEP_SNAPSHOTS,
702
+ FORECAST_URL,
703
+ MARINE_URL,
704
+ distanceNm,
705
+ interpolatePosition,
706
+ sampleRoutePoints,
707
+ routeDistanceNm,
708
+ dewpointC,
709
+ kIndex,
710
+ parseUtcTimestamp,
711
+ normalizeLocations,
712
+ buildTimeSteps,
713
+ fetchWeatherAlongTrack,
714
+ savePayload,
715
+ loadPayload,
716
+ listCachedRoutes,
717
+ };