@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.
- package/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- 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
|
+
};
|