@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,746 @@
1
+ /**
2
+ * Automated NAVAREA & NAVTEX filtering engine (work doc #4): turns
3
+ * monolithic high-seas text bulletins into geographically relevant,
4
+ * segmented blocks.
5
+ *
6
+ * Pipeline: strip transmission boilerplate → NAVTEX subject filter →
7
+ * segment into semantic blocks on GMDSS anchors → extract geography
8
+ * (coordinate chains, cardinal bounds) → spatially filter against
9
+ * the vessel track → keep only what matters.
10
+ *
11
+ * Geography is computed for filtering, not drawn: blocks carry their
12
+ * geometry type, never the polygon. Antimeridian crossings (the
13
+ * South Pacific's `178W` next to `170E`) are handled by testing
14
+ * longitudes in an unwrapped frame.
15
+ *
16
+ * Spatial tests reuse the hazard engine's ray-casting
17
+ * (`pointInPolygon` from the shared route-sim module, ring order
18
+ * [lat, lon]).
19
+ *
20
+ * Pure logic, zero dependencies, injectable fetch — unit-testable.
21
+ *
22
+ * @file bulletin-engine.js
23
+ */
24
+
25
+ /** Severe weather keywords highlighted in the UI. */
26
+ const SEVERE_KEYWORDS = [
27
+ "GALE",
28
+ "STORM",
29
+ "HURRICANE FORCE",
30
+ "SQUALL",
31
+ "VIOLENT STORM",
32
+ "ROUGH SEAS",
33
+ "VERY ROUGH SEAS",
34
+ "HIGH SEAS",
35
+ "PHENOMENAL SEAS",
36
+ ];
37
+
38
+ /**
39
+ * Retained NAVTEX B_2 subject indicators (work doc #4): A navigational
40
+ * warnings, B gale warnings, E meteorological forecasts. The rest
41
+ * (C ice reports, D SAR, …) is dropped for the weather briefing.
42
+ */
43
+ const RETAINED_SUBJECTS = "ABE";
44
+
45
+ /** GMDSS section anchors that start a new semantic block. The
46
+ * leading-dot NWS style (`.WARNINGS.`, `.SYNOPSIS AND FORECAST.`)
47
+ * is tolerated alongside the GMDSS `PART 1` headings. */
48
+ const SECTION_ANCHORS =
49
+ /^\.?\s*(PARTS?\s+\d+\b.*(?:WARNING|SYNOPSIS|FORECAST)|WARNINGS?|SYNOPSIS(?:\s+AND\s+FORECAST)?|FORECAST)\b/i;
50
+
51
+ /**
52
+ * Strips transmission routing boilerplate: NAVTEX `ZCZC`/`NNNN`
53
+ * framing, routing headers like `FQPS01 NFFN` and repeated blank
54
+ * lines. The NAVTEX message id line (`ZCZC GA05 011200Z AUG 26`) is
55
+ * kept — the subject filter reads it first.
56
+ *
57
+ * @param {string} text - Raw bulletin text
58
+ * @returns {string} Cleaned text
59
+ */
60
+ function stripBoilerplate(text) {
61
+ return text
62
+ .split(/\r?\n/)
63
+ .filter((line) => {
64
+ const trimmed = line.trim();
65
+ if (/^NNNN$/.test(trimmed)) {
66
+ return false; // End-of-message frame
67
+ }
68
+ if (/^[A-Z]{4}\d{2}\s+[A-Z]{4}\s+\d{6}Z/.test(trimmed)) {
69
+ return false; // Routing header (FQPS01 NFFN 011200Z AUG 26)
70
+ }
71
+ return true;
72
+ })
73
+ .join("\n")
74
+ .replace(/\n{3,}/g, "\n\n")
75
+ .trim();
76
+ }
77
+
78
+ /**
79
+ * Extracts the NAVTEX B_2 subject indicator from a message.
80
+ * `ZCZC <B1><B2><B3><B4>`: B_1 is the broadcast station (e.g. G for
81
+ * Nadi radio), B_2 is the subject (A navigational warnings, B gale,
82
+ * …), B_3B_4 the serial number — so `ZCZC GA14` is subject A.
83
+ *
84
+ * @param {string} text
85
+ * @returns {string|null} Subject letter (A–Z), null when not NAVTEX
86
+ */
87
+ function navtexSubject(text) {
88
+ const match = text.match(/\bZCZC\s+[A-Z]([A-Z])\d{2}\b/);
89
+ return match ? match[1] : null;
90
+ }
91
+
92
+ /**
93
+ * Decides whether a NAVTEX subject is retained for the briefing.
94
+ * Non-NAVTEX bulletins (no subject) are always retained.
95
+ *
96
+ * @param {string|null} subject - B_2 indicator
97
+ * @param {string} [retained=RETAINED_SUBJECTS] - Retained letters
98
+ * @returns {boolean}
99
+ */
100
+ function shouldRetainSubject(subject, retained = RETAINED_SUBJECTS) {
101
+ if (subject == null) {
102
+ return true;
103
+ }
104
+ return retained.toUpperCase().includes(subject.toUpperCase());
105
+ }
106
+
107
+ /**
108
+ * Segments cleaned bulletin text into semantic blocks: first on
109
+ * GMDSS section anchors (PART 1 WARNING, SYNOPSIS AND FORECAST,
110
+ * …), then on blank lines within each section.
111
+ *
112
+ * @param {string} text - Cleaned bulletin text
113
+ * @returns {string[]} Non-empty blocks
114
+ */
115
+ function segmentBlocks(text) {
116
+ const lines = text.split(/\r?\n/);
117
+ const sections = [];
118
+ let current = [];
119
+ for (const line of lines) {
120
+ if (SECTION_ANCHORS.test(line.trim()) && current.length > 0) {
121
+ sections.push(current);
122
+ current = [];
123
+ }
124
+ current.push(line);
125
+ }
126
+ if (current.length > 0) {
127
+ sections.push(current);
128
+ }
129
+ // Blank-line split within sections, keeping substantive paragraphs
130
+ const blocks = [];
131
+ for (const section of sections) {
132
+ let paragraph = [];
133
+ const flush = () => {
134
+ const text = paragraph.join("\n").trim();
135
+ if (text.length > 0) {
136
+ blocks.push(text);
137
+ }
138
+ paragraph = [];
139
+ };
140
+ for (const line of section) {
141
+ if (line.trim().length === 0) {
142
+ flush();
143
+ } else {
144
+ paragraph.push(line);
145
+ }
146
+ }
147
+ flush();
148
+ }
149
+ return blocks;
150
+ }
151
+
152
+ /**
153
+ * Parses hemisphere-suffixed coordinates: `16S` → −16, `178W` →
154
+ * −178, `170E` → 170.
155
+ *
156
+ * @param {string} value - Digits
157
+ * @param {string} hemisphere - N|S|E|W
158
+ * @returns {number} Signed degrees
159
+ */
160
+ function hemisphereDegrees(value, hemisphere) {
161
+ const degrees = Number.parseFloat(value);
162
+ if (!Number.isFinite(degrees)) {
163
+ return NaN;
164
+ }
165
+ const sign = hemisphere === "S" || hemisphere === "W" ? -1 : 1;
166
+ return sign * degrees;
167
+ }
168
+
169
+ /**
170
+ * Parses coordinate chains like `16S 170E 20S 178W` into a GeoJSON
171
+ * ring (lon/lat pairs, unclosed input closed automatically).
172
+ *
173
+ * @param {string} text
174
+ * @returns {number[][]|null} [[lon, lat], …] closed, or null
175
+ */
176
+ function parseCoordinateChain(text) {
177
+ const pair = /(\d+(?:\.\d+)?)\s*([NS])[,\s]+(\d+(?:\.\d+)?)\s*([EW])/gi;
178
+ const ring = [];
179
+ let match;
180
+ while ((match = pair.exec(text)) !== null) {
181
+ const lat = hemisphereDegrees(match[1], match[2].toUpperCase());
182
+ const lon = hemisphereDegrees(match[3], match[4].toUpperCase());
183
+ if (Number.isFinite(lat) && Number.isFinite(lon)) {
184
+ ring.push([lon, lat]);
185
+ }
186
+ }
187
+ if (ring.length < 2) {
188
+ return null;
189
+ }
190
+ // Two pairs form an open trough/front axis line; three or more
191
+ // close into a polygon ring
192
+ if (ring.length >= 3) {
193
+ const first = ring[0];
194
+ const last = ring[ring.length - 1];
195
+ if (first[0] !== last[0] || first[1] !== last[1]) {
196
+ ring.push([...first]);
197
+ }
198
+ }
199
+ return ring;
200
+ }
201
+
202
+ /**
203
+ * Parses cardinal bounding statements (`SOUTH OF 12S AND WEST OF
204
+ * 173W`) into a [minLon, minLat, maxLon, maxLat] box. When both an
205
+ * east-of and a west-of bound are present the box spans east of the
206
+ * first and west of the second — across the antimeridian when the
207
+ * east bound is numerically greater — and maxLon is unwrapped (+360)
208
+ * so the intersection test can treat it as a plain range. Callers
209
+ * must not compare these longitudes against raw geo longitudes
210
+ * directly.
211
+ *
212
+ * @param {string} text
213
+ * @returns {number[]|null} [minLon, minLat, maxLon, maxLat] (maxLon
214
+ * may exceed 180 for seam-crossing boxes), null when no bounds
215
+ * found
216
+ */
217
+ function parseCardinalBounds(text) {
218
+ let minLat = -90;
219
+ let maxLat = 90;
220
+ let found = false;
221
+
222
+ // SOUTH OF x means lat ≤ x (x becomes the box's max), NORTH OF x
223
+ // means lat ≥ x (the box's min). The bound's own hemisphere only
224
+ // sets its sign.
225
+ const southOf = text.match(/SOUTH OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
226
+ if (southOf) {
227
+ maxLat = hemisphereDegrees(southOf[1], southOf[2].toUpperCase());
228
+ found = true;
229
+ }
230
+ const northOf = text.match(/NORTH OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
231
+ if (northOf) {
232
+ minLat = hemisphereDegrees(northOf[1], northOf[2].toUpperCase());
233
+ found = true;
234
+ }
235
+
236
+ // EAST OF x means lon ≥ x; WEST OF y means lon ≤ y. Both present:
237
+ // the box runs from x eastwards to y (unwrapping y across the seam
238
+ // when y < x). Only one: half a world against the fixed bound.
239
+ const eastOf = text.match(/EAST OF\s+(\d+(?:\.\d+)?)\s*([EW])/i);
240
+ const westOf = text.match(/WEST OF\s+(\d+(?:\.\d+)?)\s*([EW])/i);
241
+ // NFFN phrasing: "BETWEEN 165W AND 135W" — a longitude pair
242
+ const between = text.match(
243
+ /BETWEEN\s+(\d+(?:\.\d+)?)\s*([EW])\s+AND\s+(\d+(?:\.\d+)?)\s*([EW])/i,
244
+ );
245
+ const eastBound = eastOf
246
+ ? hemisphereDegrees(eastOf[1], eastOf[2].toUpperCase())
247
+ : between
248
+ ? Math.min(
249
+ hemisphereDegrees(between[1], between[2].toUpperCase()),
250
+ hemisphereDegrees(between[3], between[4].toUpperCase()),
251
+ )
252
+ : null;
253
+ const westBound = westOf
254
+ ? hemisphereDegrees(westOf[1], westOf[2].toUpperCase())
255
+ : between
256
+ ? Math.max(
257
+ hemisphereDegrees(between[1], between[2].toUpperCase()),
258
+ hemisphereDegrees(between[3], between[4].toUpperCase()),
259
+ )
260
+ : null;
261
+
262
+ let minLon;
263
+ let maxLon;
264
+ if (eastBound != null && westBound != null) {
265
+ minLon = eastBound;
266
+ maxLon = westBound;
267
+ if (maxLon < minLon) {
268
+ maxLon += 360;
269
+ }
270
+ found = true;
271
+ } else if (eastBound != null) {
272
+ minLon = eastBound;
273
+ maxLon = 180;
274
+ found = true;
275
+ } else if (westBound != null) {
276
+ minLon = -180;
277
+ maxLon = westBound;
278
+ found = true;
279
+ } else {
280
+ minLon = -180;
281
+ maxLon = 180;
282
+ }
283
+ return found ? [minLon, minLat, maxLon, maxLat] : null;
284
+ }
285
+
286
+ /**
287
+ * Extracts the geographic geometry of a block: coordinate chain
288
+ * first (polygon), cardinal bounds second (bbox). Front/trough
289
+ * "axis" chains are lines, with the warning living in a band around
290
+ * them ("WITHIN 120NM EAST OF AXIS") — when the text declares a
291
+ * distance the geometry carries `bufferNm` so the intersection test
292
+ * expands to the band instead of the bare line.
293
+ *
294
+ * @param {string} blockText
295
+ * @returns {{type: "polygon", coordinates: number[][], bufferNm?: number}|
296
+ * {type: "bbox", coordinates: number[]}|null}
297
+ */
298
+ function extractGeometry(blockText) {
299
+ const bandMatch = blockText.match(
300
+ /WITHIN\s+(\d+(?:\.\d+)?)\s*(?:NM|NAUTICAL\s+MILES?)\b/i,
301
+ );
302
+ const bufferNm = bandMatch ? Number.parseFloat(bandMatch[1]) : null;
303
+ const polygon = parseCoordinateChain(blockText);
304
+ if (polygon) {
305
+ return {
306
+ // Two coordinate pairs are a trough/front axis line, three or
307
+ // more close into an area polygon
308
+ type: polygon.length === 2 ? "line" : "polygon",
309
+ coordinates: polygon,
310
+ ...(bufferNm != null ? { bufferNm } : {}),
311
+ };
312
+ }
313
+ const bbox = parseCardinalBounds(blockText);
314
+ if (bbox) {
315
+ return { type: "bbox", coordinates: bbox };
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Unwraps a longitude for antimeridian-safe comparison: returns a
322
+ * candidate ≥ reference choosing the representative closest to it
323
+ * (lon or lon ± 360).
324
+ *
325
+ * @param {number} lon
326
+ * @param {number} reference
327
+ * @returns {number}
328
+ */
329
+ function unwrapLon(lon, reference) {
330
+ let value = lon;
331
+ while (value - reference > 180) {
332
+ value -= 360;
333
+ }
334
+ while (reference - value > 180) {
335
+ value += 360;
336
+ }
337
+ return value;
338
+ }
339
+
340
+ /**
341
+ * Ray-casting containment for a GeoJSON ring (lon/lat, closed or
342
+ * unclosed). The ring is unwrapped against itself once (sequential
343
+ * shortest-arc), so a polygon spanning the antimeridian stays
344
+ * contiguous; the query longitude is brought into that frame via
345
+ * its nearest representative.
346
+ *
347
+ * @param {number[][]} ring - [[lon, lat], …]
348
+ * @param {number} lon
349
+ * @param {number} lat
350
+ * @returns {boolean}
351
+ */
352
+ function ringContains(ring, lon, lat) {
353
+ if (ring.length === 0) {
354
+ return false;
355
+ }
356
+ // Unwrap the ring in its own frame (running shortest-arc)
357
+ const lons = [ring[0][0]];
358
+ for (let i = 1; i < ring.length; i++) {
359
+ lons.push(unwrapLon(ring[i][0], lons[i - 1]));
360
+ }
361
+ // Bring the query into the ring's frame
362
+ const center = (Math.min(...lons) + Math.max(...lons)) / 2;
363
+ const query = unwrapLon(lon, center);
364
+
365
+ let inside = false;
366
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
367
+ const xi = lons[i];
368
+ const yi = ring[i][1];
369
+ const xj = lons[j];
370
+ const yj = ring[j][1];
371
+ const intersects =
372
+ yi > lat !== yj > lat &&
373
+ query < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
374
+ if (intersects) {
375
+ inside = !inside;
376
+ }
377
+ }
378
+ return inside;
379
+ }
380
+
381
+ /**
382
+ * Tests whether a polygon intersects any of the track points
383
+ * (midpoints included, so a track leg through a small polygon is
384
+ * caught even with sparse waypoints).
385
+ *
386
+ * @param {number[][]} ring - GeoJSON ring [[lon, lat], …]
387
+ * @param {number[][]} track - Track points [[lon, lat], …]
388
+ * @returns {boolean}
389
+ */
390
+ function polygonIntersectsTrack(ring, track) {
391
+ if (track.length === 0) {
392
+ return false;
393
+ }
394
+ const points = [];
395
+ for (let i = 0; i < track.length; i++) {
396
+ points.push(track[i]);
397
+ if (i > 0) {
398
+ points.push([
399
+ (track[i - 1][0] + track[i][0]) / 2,
400
+ (track[i - 1][1] + track[i][1]) / 2,
401
+ ]);
402
+ }
403
+ }
404
+ return points.some(([lon, lat]) => ringContains(ring, lon, lat));
405
+ }
406
+
407
+ /**
408
+ * Tests whether a bbox intersects the track, with antimeridian
409
+ * wrap: a point matches when its longitude (or longitude ± 360)
410
+ * falls inside the box's lon range.
411
+ *
412
+ * @param {number[]} bbox - [minLon, minLat, maxLon, maxLat]
413
+ * @param {number[][]} track - Track points [[lon, lat], …]
414
+ * @returns {boolean}
415
+ */
416
+ function bboxIntersectsTrack(bbox, track) {
417
+ const [minLon, minLat, maxLon, maxLat] = bbox;
418
+ return track.some(([lon, lat]) => {
419
+ if (lat < Math.min(minLat, maxLat) || lat > Math.max(minLat, maxLat)) {
420
+ return false;
421
+ }
422
+ for (const candidate of [lon, lon + 360, lon - 360]) {
423
+ if (
424
+ candidate >= Math.min(minLon, maxLon) &&
425
+ candidate <= Math.max(minLon, maxLon)
426
+ ) {
427
+ return true;
428
+ }
429
+ }
430
+ return false;
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Whether a block's geometry intersects the track. Blocks without
436
+ * extractable geometry are kept (cannot be placed — a conservative
437
+ * miss beats silently losing a warning). Axis-line polygons with a
438
+ * declared band ("WITHIN 120NM") expand their bounding box by the
439
+ * band before the test: the warning area is the band, not the line.
440
+ *
441
+ * @param {{type: "polygon"|"bbox", coordinates: number[][]|number[],
442
+ * bufferNm?: number}}|null geometry
443
+ * @param {number[][]} track - Track points [[lon, lat], …]
444
+ * @returns {boolean} True when the block should be kept
445
+ */
446
+ function intersectsTrack(geometry, track) {
447
+ if (!geometry) {
448
+ return true;
449
+ }
450
+ if (track.length === 0) {
451
+ return true;
452
+ }
453
+ if (geometry.type === "polygon" || geometry.type === "line") {
454
+ if (polygonIntersectsTrack(geometry.coordinates, track)) {
455
+ return true;
456
+ }
457
+ // Band expansion for axis lines and WITHIN-nm areas. Chains that
458
+ // cross the antimeridian are unfolded into a continuous frame and
459
+ // track longitudes are tested in all three representations.
460
+ if (geometry.bufferNm > 0) {
461
+ const lats = geometry.coordinates.map(([, lat]) => lat);
462
+ const meanLat =
463
+ ((Math.min(...lats) + Math.max(...lats)) / 2) * (Math.PI / 180);
464
+ const latPad = geometry.bufferNm / 60;
465
+ const lonPad = latPad / Math.max(0.2, Math.cos(meanLat));
466
+ const unfolded = [];
467
+ let prev = null;
468
+ for (const [lon] of geometry.coordinates) {
469
+ let value = prev == null ? lon : lon;
470
+ if (prev != null) {
471
+ while (value - prev > 180) {
472
+ value -= 360;
473
+ }
474
+ while (value - prev < -180) {
475
+ value += 360;
476
+ }
477
+ }
478
+ unfolded.push(value);
479
+ prev = value;
480
+ }
481
+ const minLon = Math.min(...unfolded) - lonPad;
482
+ const maxLon = Math.max(...unfolded) + lonPad;
483
+ const minLat = Math.min(...lats) - latPad;
484
+ const maxLat = Math.max(...lats) + latPad;
485
+ return track.some(([lon, lat]) =>
486
+ [0, 360, -360].some((shift) => {
487
+ const shifted = lon + shift;
488
+ return (
489
+ shifted >= minLon &&
490
+ shifted <= maxLon &&
491
+ lat >= minLat &&
492
+ lat <= maxLat
493
+ );
494
+ }),
495
+ );
496
+ }
497
+ return false;
498
+ }
499
+ if (geometry.type === "bbox") {
500
+ return bboxIntersectsTrack(geometry.coordinates, track);
501
+ }
502
+ return true;
503
+ }
504
+
505
+ /**
506
+ * Runs the full pipeline over one raw bulletin (work doc #4 §2–4):
507
+ * boilerplate stripping, NAVTEX subject filter, GMDSS segmentation,
508
+ * geographic extraction and track filtering.
509
+ *
510
+ * @param {object} params
511
+ * @param {string} params.rawText - Raw bulletin text
512
+ * @param {"api"|"spool"} params.source - Ingestion source
513
+ * @param {number[][]} [params.track] - Track points [[lon, lat], …]
514
+ * (route waypoints, current position first)
515
+ * @param {string} [params.retainedSubjects] - NAVTEX B_2 letters to
516
+ * keep (default {@link RETAINED_SUBJECTS})
517
+ * @param {string} [params.issuedAt] - Issue time override (spool
518
+ * file parsing result); extracted from the text otherwise
519
+ * @returns {object|null} `{header, issuedAt, bulletinText, source,
520
+ * blocks: [{text, subject, geometryType, source}]}` — null when
521
+ * the whole message is discarded by the subject filter
522
+ */
523
+ function filterBulletin({
524
+ rawText,
525
+ source,
526
+ track = [],
527
+ retainedSubjects = RETAINED_SUBJECTS,
528
+ issuedAt,
529
+ }) {
530
+ const subject = navtexSubject(rawText);
531
+ if (!shouldRetainSubject(subject, retainedSubjects)) {
532
+ return null;
533
+ }
534
+ const cleaned = stripBoilerplate(rawText);
535
+ const header = cleaned.split(/\r?\n/, 1)[0]?.trim() ?? "";
536
+ const blocks = segmentBlocks(cleaned)
537
+ .map((blockText) => {
538
+ const geometry = extractGeometry(blockText);
539
+ if (!intersectsTrack(geometry, track)) {
540
+ return null; // Discard rule: not on our waters
541
+ }
542
+ return {
543
+ text: blockText,
544
+ subject: subject ?? null,
545
+ geometryType: geometry ? geometry.type : null,
546
+ geometry: geometry ?? null,
547
+ source,
548
+ };
549
+ })
550
+ .filter(Boolean);
551
+
552
+ const spoolWatcher = require("./spool-watcher.js");
553
+ return {
554
+ header,
555
+ issuedAt:
556
+ issuedAt ??
557
+ spoolWatcher.extractIssuedAt(cleaned) ??
558
+ new Date(0).toISOString(),
559
+ bulletinText: rawText,
560
+ source,
561
+ blocks,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Pulls a remote bulletin over HTTP(S) (work doc #4 §1: the
567
+ * online-transition triggered REST/RSS pull). The response is plain
568
+ * text run through the same pipeline as spool files.
569
+ *
570
+ * @param {string} url
571
+ * @param {object} [params]
572
+ * @param {typeof fetch} [params.fetchImpl]
573
+ * @param {number} [params.timeoutMs]
574
+ * @returns {Promise<string>} Raw bulletin text
575
+ */
576
+ async function fetchRemoteBulletin(
577
+ url,
578
+ { fetchImpl = fetch, timeoutMs = 15000 } = {},
579
+ ) {
580
+ const controller = new AbortController();
581
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
582
+ try {
583
+ const response = await fetchImpl(url, {
584
+ signal: controller.signal,
585
+ headers: { Accept: "text/plain" },
586
+ });
587
+ if (!response.ok) {
588
+ throw new Error(`${response.status} ${response.statusText}`);
589
+ }
590
+ return await response.text();
591
+ } finally {
592
+ clearTimeout(timer);
593
+ }
594
+ }
595
+
596
+ const API_WEATHER_GOV_TYPES =
597
+ /^https:\/\/api\.weather\.gov\/products\/types\/([A-Z0-9]+)\/locations\/([A-Z0-9]+)/i;
598
+
599
+ /**
600
+ * Resolves a bulletin source URL to raw text. Plain URLs are
601
+ * fetched verbatim; `api.weather.gov` product-type URLs resolve
602
+ * two-step (latest iteration id → product JSON) and return the
603
+ * embedded `productText` — the NWS High Seas Forecasts.
604
+ *
605
+ * @param {string} url
606
+ * @param {object} [params]
607
+ * @param {typeof fetch} [params.fetchImpl]
608
+ * @param {number} [params.timeoutMs]
609
+ * @returns {Promise<{text: string, source: string}>} Text plus a
610
+ * provenance label ("api" for resolved feeds, "spool" never here)
611
+ */
612
+ async function resolveBulletinSource(
613
+ url,
614
+ { fetchImpl = fetch, timeoutMs } = {},
615
+ ) {
616
+ const apiMatch = url.match(API_WEATHER_GOV_TYPES);
617
+ if (!apiMatch) {
618
+ const controller = new AbortController();
619
+ const timer = setTimeout(() => controller.abort(), timeoutMs ?? 15000);
620
+ try {
621
+ const response = await fetchImpl(url, {
622
+ signal: controller.signal,
623
+ headers: { Accept: "text/plain" },
624
+ });
625
+ if (!response.ok) {
626
+ throw new Error(`${response.status} ${response.statusText}`);
627
+ }
628
+ return { text: await response.text(), source: "api" };
629
+ } finally {
630
+ clearTimeout(timer);
631
+ }
632
+ }
633
+
634
+ // api.weather.gov: latest iteration of the product type/location
635
+ const listUrl = `${apiMatch[0]}?limit=1`;
636
+ const get = async (u) => {
637
+ const controller = new AbortController();
638
+ const timer = setTimeout(() => controller.abort(), timeoutMs ?? 15000);
639
+ try {
640
+ const response = await fetchImpl(u, {
641
+ signal: controller.signal,
642
+ headers: { Accept: "application/geo+json, application/json" },
643
+ });
644
+ if (!response.ok) {
645
+ throw new Error(`${response.status} ${response.statusText}`);
646
+ }
647
+ return response.json();
648
+ } finally {
649
+ clearTimeout(timer);
650
+ }
651
+ };
652
+ const list = await get(listUrl);
653
+ const latest = (list?.["@graph"] ?? [])[0];
654
+ if (!latest?.id) {
655
+ throw new Error("No product iterations available");
656
+ }
657
+ const product = await get(latest.id);
658
+ const text = product?.productText;
659
+ if (typeof text !== "string" || text.length === 0) {
660
+ throw new Error("Product has no text");
661
+ }
662
+ return { text, source: "api" };
663
+ }
664
+
665
+ /**
666
+ * Filters structured UKHO MSI warnings against the track (work doc
667
+ * #9: coordinates feed the intersection directly, bypassing the regex
668
+ * engine). Output blocks match the `filterBulletin` block shape so
669
+ * both ingestion paths merge into one console.
670
+ *
671
+ * @param {Array<{text: string, issuedAt: string|null,
672
+ * coordinates: number[][]}>} warnings - `parseUkhoWarnings` output
673
+ * @param {number[][]} track - Track points [[lon, lat], …]
674
+ * @param {object} [options]
675
+ * @param {string} [options.source] - Block source label
676
+ * @returns {Array<{text: string, subject: null, geometryType: string|null,
677
+ * source: string}>}
678
+ */
679
+ function ukhoBlocksFromWarnings(warnings, track, { source = "ukho" } = {}) {
680
+ const blocks = [];
681
+ for (const warning of warnings ?? []) {
682
+ if (!warning?.text) {
683
+ continue;
684
+ }
685
+ const geometry = ukhoGeometry(warning);
686
+ if (!intersectsTrack(geometry, track)) {
687
+ continue; // Same discard rule as the text pipeline
688
+ }
689
+ blocks.push({
690
+ text: warning.text,
691
+ subject: null,
692
+ geometryType: geometry ? geometry.type : null,
693
+ geometry: geometry ?? null,
694
+ source,
695
+ });
696
+ }
697
+ return blocks;
698
+ }
699
+
700
+ /**
701
+ * Geometry for one normalized UKHO warning: a ring of three or more
702
+ * coordinate pairs becomes a polygon, a single point a small bbox so
703
+ * it still intersects by containment.
704
+ *
705
+ * @param {object} warning
706
+ * @returns {object|null} `extractGeometry`-compatible shape
707
+ */
708
+ function ukhoGeometry(warning) {
709
+ const points = warning?.coordinates ?? [];
710
+ if (points.length >= 3) {
711
+ return { type: "polygon", coordinates: points };
712
+ }
713
+ if (points.length === 1) {
714
+ const [lon, lat] = points[0];
715
+ // ~3 nm point-hazard box so single coordinates intersect by
716
+ // containment like a tiny area would
717
+ return {
718
+ type: "bbox",
719
+ coordinates: [lon - 0.05, lat - 0.05, lon + 0.05, lat + 0.05],
720
+ };
721
+ }
722
+ return null;
723
+ }
724
+
725
+ module.exports = {
726
+ RETAINED_SUBJECTS,
727
+ SEVERE_KEYWORDS,
728
+ SECTION_ANCHORS,
729
+ stripBoilerplate,
730
+ navtexSubject,
731
+ shouldRetainSubject,
732
+ segmentBlocks,
733
+ ukhoBlocksFromWarnings,
734
+ hemisphereDegrees,
735
+ parseCoordinateChain,
736
+ parseCardinalBounds,
737
+ extractGeometry,
738
+ unwrapLon,
739
+ ringContains,
740
+ polygonIntersectsTrack,
741
+ bboxIntersectsTrack,
742
+ intersectsTrack,
743
+ filterBulletin,
744
+ fetchRemoteBulletin,
745
+ resolveBulletinSource,
746
+ };