@meri-imperiumi/signalk-passage-briefing 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,101 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - Sail changes are scheduled when the crew can act on them:
10
+ recommendation-driven canvas changes anchor to the *previous watch
11
+ handover* when a watch schedule is running (signalk-watch-schedule;
12
+ boundaries are extrapolated across the forecast horizon from the
13
+ rotation cycle, and the watch boundary wins over sunlight),
14
+ otherwise to the next sunrise/sunset. Several detections between
15
+ boundaries collapse into one change carrying the last suggested
16
+ state, no-op re-rigs are dropped, and the timeline says which
17
+ anchor applied ("watch change" / "at dusk" / "at dawn"). Tacks and
18
+ gybes stay at their tactical times.
19
+ - Unified passage timeline: a new `<passage-timeline>` component and
20
+ `mergeTimeline()` view model render every event source — sail
21
+ changes, planned tacks and gybes, convective risk, macro sea state,
22
+ territorial waters transitions, sky events and hazard notes — as
23
+ one chronological list with a time gutter, kind glyph and severity
24
+ colour. The tactical dashboard renders the 24 h slice, the
25
+ strategic outlook the whole passage; the per-type blocks (Sail
26
+ Work, Convective Risk, Macro Sea State, Sky Notes) and the tactical
27
+ hazard banners are replaced by it. The exception view now carries
28
+ the whole-route hazard list in `passageSummary.hazards` (the 24 h
29
+ `next24h.sailChanges` and `next24h.hazards` slices are gone — the
30
+ timeline slices itself).
31
+ - Sail-change events in the timeline carry the forecast conditions
32
+ at the change point (nearest simulated hour): true wind, sea state
33
+ and Sereno comfort tier — "Main 1 reef — 14.2 kn TWS · Hs 1.5 m ·
34
+ coffee" — so the crew knows what they are rigging into. The
35
+ simulation's hourly rows now include wave height and period.
36
+ - Ship's time display: the webapp reads the vessel's published
37
+ timezone (`environment.time.timezoneOffset` / `.timezoneRegion`, as
38
+ served by signalk-ships-time) over the REST API and the delta
39
+ stream, and renders briefing stamps in ship's time (`MM-DD HH:MM
40
+ +13`) instead of UTC, falling back to UTC `Z` when no offset is
41
+ published. The offset rides on every stamp so a zone crossing
42
+ mid-passage reads honestly; a header pill names the zone (IANA
43
+ region when known, else the offset).
44
+ - Weather source selection (`weather_source`: `auto` / `weather-api` /
45
+ `open-meteo`, default `auto`): when the server has the Weather API
46
+ and a provider answers — signalk-weather-router-plus serving its
47
+ decoded ECMWF run — waypoint forecasts for the here and route
48
+ briefing windows are read from it in-process (`app.weatherApi`)
49
+ instead of Open-Meteo, so the briefing reasons from the same
50
+ forecast the router planned with and offshore fetches stay local.
51
+ Provider responses (Signal K units) map onto the payload
52
+ conventions (knots, degrees true, hPa); combined sea only — swell
53
+ and wind-sea partitions and the upper-air fields behind the
54
+ convective warnings degrade to absent. A failed Weather API fetch
55
+ falls back to Open-Meteo for that window; the forced choices never
56
+ fall back.
57
+
58
+ ### Changed
59
+
60
+ - Convective warnings read as episodes, not hourly spam: consecutive
61
+ anomalies (and steep-sea anomalies alike) merge into one timeline
62
+ event with a time range and peak values — "CAPE 713 J/kg · K 28.6 ·
63
+ until 10-05 05:38Z". Severity follows the peak: CAPE ≥ 400 J/kg (the
64
+ bar where weather services start coloring the index) or K-index ≥ 30
65
+ is a red alert, while the unstable-air band below it (K ≥ 28 with
66
+ low CAPE) still shows as an orange warning with units and sane
67
+ precision instead of being dropped or inflated.
68
+
69
+ ### Fixed
70
+
71
+ - The webapp flags stale cached briefings: when a served payload was
72
+ compiled more than a day ago, a banner above the briefing shows the
73
+ compile stamp and age with a Fetch now affordance, instead of
74
+ presenting a multi-day-old timeline's `+Xh` labels as upcoming.
75
+ - The plugin re-fetches stale briefings without waiting for an edge
76
+ trigger: the oneshot fires only when the internet state changes and
77
+ cron windows only run while moored and charged, so a server that
78
+ stays up for days while the machine sits in STANDBY_OFFSHORE kept
79
+ serving a briefing sliding into the past. The one-minute ticker now
80
+ re-fetches (trigger `stale`) when online and the cached briefing
81
+ (active route, else last briefed, else here) is older than the
82
+ route TTL — at most once per six hours, and only when something is
83
+ actually cached.
84
+
85
+ - Bulletin geography now resolves named synoptic features in area
86
+ bounds: `SOUTH OF 09S AND WEST OF CF` and `SOUTH OF 10S, BETWEEN
87
+ 150W AND CF` compose a polygon from the cold front's defining
88
+ chain (clipped to the stated latitude bounds, closed across the
89
+ antimeridian with a margin so western-Pacific vessels stay in
90
+ west-of-front areas) instead of falling back to a hemisphere-wide
91
+ box that matched every vessel south of the bound.
92
+
93
+ - Coordinate chains accept the Fiji/NFFN bulletin conventions the
94
+ strict parser dropped: the dateline written as bare `180` (`TROUGH
95
+ T3 12S 175E 14S 180 15S 177W`) and the equator written as `EQT`
96
+ (`EQT 177E`). Prose numbers (`280600 UTC`, `20 TO 30 KNOTS`) are
97
+ still rejected, and a rejected span no longer swallows a following
98
+ coordinate pair.
99
+
5
100
  ## [0.3.0] - 2026-09-28
6
101
 
7
102
  ### Added
package/README.md CHANGED
@@ -3,12 +3,7 @@
3
3
  Offshore passage daily briefing webapp for Signal K: a plugin that plans
4
4
  and reviews passages for a cruising sailing vessel.
5
5
 
6
- The plugin fetches multi-model weather along the planned route (online,
7
- or via a GRIB/text spool when offline offshore), runs a step-forward
8
- isochrone simulation with a monohull comfort model, learns the crew's
9
- sail preferences from the electronic logbook, and serves a two-screen
10
- webapp: a 24-hour tactical dashboard and a strategic passage summary.
11
- See [SPEC.md](SPEC.md) for the full design.
6
+ The plugin fetches weather along the planned route — from the server's Weather API when a provider answers, Open-Meteo otherwise; fetched online, or via a GRIB/text spool when offline offshore. It runs a step-forward isochrone simulation with a monohull comfort model, learns the crew's sail preferences from the electronic logbook, and serves a two-screen webapp: a 24-hour tactical dashboard and a strategic passage summary. See [SPEC.md](SPEC.md) for the full design.
12
7
 
13
8
  Part of the Lille Ø offshore suite, alongside
14
9
  [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictator)
@@ -38,11 +33,34 @@ and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-
38
33
  - [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook)
39
34
  store — crewed sail events (reefs, sail changes) that train the
40
35
  preference matrix
36
+ - [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time)
37
+ — `environment.time.timezoneOffset` / `.timezoneRegion`, the
38
+ vessel's published timezone: briefing stamps render in ship's time
39
+ when available (the offset rides on every stamp), falling back to
40
+ UTC `Z` when nothing is published
41
+ - [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule)
42
+ — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and
43
+ `watch.schedule`: the running watch rotation. While a watch is
44
+ running, planned sail changes anchor to the previous watch handover
45
+ (both teams awake) instead of sunrise/sunset; boundaries are
46
+ extrapolated across the forecast horizon from the rotation cycle
41
47
  - [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration)
42
48
  — sail inventory used to filter free-text noise out of log entries
49
+ - Weather API (`app.weatherApi`) — waypoint forecasts from the
50
+ registered provider, the preferred source (`weather_source: auto`)
51
+ whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus)
52
+ serves it from its decoded ECMWF run, so briefing numbers match what
53
+ the router planned with. That source carries combined sea only:
54
+ swell/wind-sea partitions and the upper-air fields behind the
55
+ convective warnings degrade to absent rather than being invented
43
56
 
44
57
  ### External
45
58
 
59
+ The Open-Meteo entries below are the fallback weather source: used
60
+ when no Weather API provider answers (or `weather_source` is
61
+ `open-meteo`), and the only source of the partition and upper-air
62
+ fields the Weather API providers don't publish.
63
+
46
64
  - [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface
47
65
  wind, gusts, MSL pressure, CAPE and the pressure-layer fields behind
48
66
  the K-index (best-match model)
@@ -70,6 +88,24 @@ and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-
70
88
  All external fetches are online-gated and cached to the plugin data
71
89
  directory, so the last payloads survive the offline hours.
72
90
 
91
+ ## Pairing with Weather Router Plus
92
+
93
+ [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus)
94
+ plans the passage: isochrone routing against the vessel's polar on the ECMWF open-data run it keeps decoded on disk, with map overlays, tides and currents. Activate the route it publishes to the Resources API, and this plugin briefs it — `navigation.course.activeRoute` wins over the last briefed route at the next fetch window, and with `weather_source: auto` (the default) the briefing reads its forecasts from the router's Weather API provider. One forecast on board, planner and briefing in agreement: the router for planning and visualization, the briefing for the underway daily routine (comfort, sail changes, bulletins, energy).
95
+
96
+ ## Pairing with Watch Schedule
97
+
98
+ [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule)
99
+ runs the crew's watch rotation and publishes it under `watch.*`. When a
100
+ watch is running, this plugin reads the schedule at app load and moves
101
+ planned sail changes (reefs, canvas work) to the *previous* watch
102
+ handover — the moment both teams are awake on deck — rather than
103
+ sunrise/sunset; tacks and gybes stay at their tactical times, since
104
+ course work can't wait for a handover. The watch boundary wins over
105
+ sunlight. Without a running watch, canvas work anchors to the next
106
+ sunrise/sunset. Webapp-side only: no configuration, and the briefing
107
+ works unchanged when the plugin is absent.
108
+
73
109
  ## Acknowledgments
74
110
 
75
111
  The comfort model and the whole idea of "what will each departure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -34,6 +34,10 @@
34
34
  "@meri-imperiumi/signalk-energy-predictor",
35
35
  "@meri-imperiumi/signalk-internet",
36
36
  "signalk-polar-management"
37
+ ],
38
+ "recommends": [
39
+ "signalk-watch-schedule",
40
+ "signalk-ships-time"
37
41
  ]
38
42
  },
39
43
  "engines": {
@@ -167,23 +167,49 @@ function hemisphereDegrees(value, hemisphere) {
167
167
  }
168
168
 
169
169
  /**
170
- * Parses coordinate chains like `16S 170E 20S 178W` into a GeoJSON
171
- * ring (lon/lat pairs, unclosed input closed automatically).
170
+ * Extracts every coordinate pair from a block as lon/lat points,
171
+ * without the ring bookkeeping. Hemisphere letters are optional to
172
+ * accept the bulletin shorthand the strict form misses: the dateline
173
+ * written as a bare `180` (`14S 180`, east by convention) and the
174
+ * equator written as `EQT` (`EQT 177E`). Pairs where both letters
175
+ * are missing are prose numbers ("280600 UTC", "20 TO 30 KNOTS")
176
+ * and rejected.
172
177
  *
173
178
  * @param {string} text
174
- * @returns {number[][]|null} [[lon, lat], …] closed, or null
179
+ * @returns {number[][]} [[lon, lat], …] (possibly empty)
175
180
  */
176
- function parseCoordinateChain(text) {
177
- const pair = /(\d+(?:\.\d+)?)\s*([NS])[,\s]+(\d+(?:\.\d+)?)\s*([EW])/gi;
178
- const ring = [];
181
+ function parseCoordinatePoints(text) {
182
+ const pair = /(\d+(?:\.\d+)?|EQT)\s*([NS])?[,\s]+(\d+(?:\.\d+)?)\s*([EW])?/gi;
183
+ const points = [];
179
184
  let match;
180
185
  while ((match = pair.exec(text)) !== null) {
181
- const lat = hemisphereDegrees(match[1], match[2].toUpperCase());
182
- const lon = hemisphereDegrees(match[3], match[4].toUpperCase());
186
+ const equator = match[1].toUpperCase() === "EQT";
187
+ const latHemi = match[2]?.toUpperCase();
188
+ const lonHemi = match[4]?.toUpperCase();
189
+ if (!equator && !latHemi && !lonHemi) {
190
+ // Prose numbers, not coordinates — resume inside the rejected
191
+ // span so it cannot swallow a following real coordinate pair
192
+ pair.lastIndex = match.index + 1;
193
+ continue;
194
+ }
195
+ const lat = equator ? 0 : hemisphereDegrees(match[1], latHemi ?? "N");
196
+ const lon = hemisphereDegrees(match[3], lonHemi ?? "E");
183
197
  if (Number.isFinite(lat) && Number.isFinite(lon)) {
184
- ring.push([lon, lat]);
198
+ points.push([lon, lat]);
185
199
  }
186
200
  }
201
+ return points;
202
+ }
203
+
204
+ /**
205
+ * Parses coordinate chains like `16S 170E 20S 178W` into a GeoJSON
206
+ * ring (lon/lat pairs, unclosed input closed automatically).
207
+ *
208
+ * @param {string} text
209
+ * @returns {number[][]|null} [[lon, lat], …] closed, or null
210
+ */
211
+ function parseCoordinateChain(text) {
212
+ const ring = parseCoordinatePoints(text);
187
213
  if (ring.length < 2) {
188
214
  return null;
189
215
  }
@@ -283,6 +309,206 @@ function parseCardinalBounds(text) {
283
309
  return found ? [minLon, minLat, maxLon, maxLat] : null;
284
310
  }
285
311
 
312
+ /**
313
+ * Synoptic feature declarations the named-bound resolution
314
+ * recognizes: `TROUGH T1`, `COLD FRONT CF`, `LOW PRESSURE L`, … The
315
+ * name must carry a digit or be one of the conventional CF, L, H so
316
+ * prose ("TROUGH AXIS") is not mistaken for a name.
317
+ */
318
+ const FEATURE_DECLARATION =
319
+ /^(?:TROUGH|COLD FRONT|WARM FRONT|SHEAR LINE|RIDGE|LOW(?:\s+PRESSURE)?|HIGH)\s+([A-Z]\d+|CF|L|H)\b/i;
320
+
321
+ /** Name-shaped token referenced by cardinal bounds. */
322
+ const FEATURE_NAME = "(CF|L|H|[A-Z]\\d+)";
323
+
324
+ /**
325
+ * Collects named synoptic features from a full bulletin: blocks that
326
+ * declare a feature and carry its defining coordinate chain —
327
+ * `COLD FRONT CF 16S 150W 20S 140W 25S 132W` — mapped name →
328
+ * chain. Later blocks reference these by name in their bounds
329
+ * ("WEST OF CF", "BETWEEN 150W AND CF").
330
+ *
331
+ * @param {string} text - Cleaned bulletin text
332
+ * @returns {Map<string, number[][]>} name → [[lon, lat], …]
333
+ */
334
+ function collectFeatures(text) {
335
+ const features = new Map();
336
+ for (const block of segmentBlocks(text)) {
337
+ const match = block.trim().match(FEATURE_DECLARATION);
338
+ if (!match || features.has(match[1].toUpperCase())) {
339
+ continue;
340
+ }
341
+ const points = parseCoordinatePoints(block);
342
+ if (points.length > 0) {
343
+ features.set(match[1].toUpperCase(), points);
344
+ }
345
+ }
346
+ return features;
347
+ }
348
+
349
+ /**
350
+ * Clips a polyline to a latitude band, interpolating where segments
351
+ * cross the band edges, so composed feature polygons never extend
352
+ * past the block's stated latitude bounds. A chain entirely outside
353
+ * the band is kept unchanged (its ends still anchor the caps).
354
+ *
355
+ * @param {number[][]} pts - [[lon, lat], …]
356
+ * @param {number} minLat
357
+ * @param {number} maxLat
358
+ * @returns {number[][]} Clipped chain
359
+ */
360
+ function clipChainToLatBand(pts, minLat, maxLat) {
361
+ if (pts.length === 0) {
362
+ return pts;
363
+ }
364
+ const inside = (lat) => lat <= maxLat && lat >= minLat;
365
+ const out = [];
366
+ if (inside(pts[0][1])) {
367
+ out.push(pts[0]);
368
+ }
369
+ for (let i = 1; i < pts.length; i++) {
370
+ const a = pts[i - 1];
371
+ const b = pts[i];
372
+ if (inside(a[1]) !== inside(b[1])) {
373
+ const bound = inside(b[1])
374
+ ? a[1] > maxLat
375
+ ? maxLat
376
+ : minLat
377
+ : b[1] > maxLat
378
+ ? maxLat
379
+ : minLat;
380
+ const t = (bound - a[1]) / (b[1] - a[1]);
381
+ out.push([a[0] + (b[0] - a[0]) * t, bound]);
382
+ }
383
+ if (inside(b[1])) {
384
+ out.push(b);
385
+ }
386
+ }
387
+ return out.length > 0 ? out : pts;
388
+ }
389
+
390
+ /**
391
+ * Builds the polygon for bounds that reference a named feature
392
+ * instead of a longitude — `SOUTH OF 09S AND WEST OF CF`, `SOUTH OF
393
+ * 10S, BETWEEN 150W AND CF`. The feature's chain forms the slanted
394
+ * edge, clipped to the block's latitude bounds; the open side closes
395
+ * on the BETWEEN meridian when one is given, otherwise on the
396
+ * antimeridian extended a margin past the seam so areas west of a
397
+ * front still match vessels in the western Pacific. The front's
398
+ * trend south of its last point is approximated by its end meridian.
399
+ *
400
+ * @param {string} text - Block text
401
+ * @param {Map<string, number[][]>} features - `collectFeatures` map
402
+ * @returns {{type: "polygon", coordinates: number[][]}|null} Closed
403
+ * ring, or null when no known feature is referenced (caller falls
404
+ * back to the numeric bbox path)
405
+ */
406
+ function composeFeatureBounds(text, features) {
407
+ const southOf = text.match(/SOUTH OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
408
+ const northOf = text.match(/NORTH OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
409
+ if (!southOf && !northOf) {
410
+ return null; // Feature refs come as area phrases with a lat bound
411
+ }
412
+ const maxLat = southOf
413
+ ? hemisphereDegrees(southOf[1], southOf[2].toUpperCase())
414
+ : 90;
415
+ const minLat = northOf
416
+ ? hemisphereDegrees(northOf[1], northOf[2].toUpperCase())
417
+ : -90;
418
+
419
+ // Which side of which feature: WEST OF/EAST OF name, or BETWEEN a
420
+ // meridian and a name (the name is then the opposite bound).
421
+ const westOf = text.match(new RegExp(`WEST OF\\s+${FEATURE_NAME}\\b`, "i"));
422
+ const eastOf = text.match(new RegExp(`EAST OF\\s+${FEATURE_NAME}\\b`, "i"));
423
+ const betweenMeridianName = text.match(
424
+ new RegExp(
425
+ `BETWEEN\\s+\\d+(?:\\.\\d+)?\\s*[EW]\\s+AND\\s+${FEATURE_NAME}\\b`,
426
+ "i",
427
+ ),
428
+ );
429
+ const betweenNameMeridian = text.match(
430
+ new RegExp(
431
+ `BETWEEN\\s+${FEATURE_NAME}\\s+AND\\s+\\d+(?:\\.\\d+)?\\s*[EW]`,
432
+ "i",
433
+ ),
434
+ );
435
+ const meridianMatch = text.match(/BETWEEN\s+(\d+(?:\.\d+)?)\s*([EW])/i);
436
+
437
+ let feature = null;
438
+ let side = null; // Region lies on this side of the feature
439
+ let meridian = null; // Explicit meridian closing the open side
440
+ if (westOf && features.has(westOf[1].toUpperCase())) {
441
+ feature = features.get(westOf[1].toUpperCase());
442
+ side = "west";
443
+ } else if (eastOf && features.has(eastOf[1].toUpperCase())) {
444
+ feature = features.get(eastOf[1].toUpperCase());
445
+ side = "east";
446
+ } else if (
447
+ betweenMeridianName &&
448
+ features.has(betweenMeridianName[1].toUpperCase())
449
+ ) {
450
+ feature = features.get(betweenMeridianName[1].toUpperCase());
451
+ side = "west";
452
+ meridian = hemisphereDegrees(
453
+ meridianMatch[1],
454
+ meridianMatch[2].toUpperCase(),
455
+ );
456
+ } else if (
457
+ betweenNameMeridian &&
458
+ features.has(betweenNameMeridian[1].toUpperCase())
459
+ ) {
460
+ feature = features.get(betweenNameMeridian[1].toUpperCase());
461
+ side = "east";
462
+ meridian = hemisphereDegrees(
463
+ meridianMatch[1],
464
+ meridianMatch[2].toUpperCase(),
465
+ );
466
+ }
467
+ if (!feature) {
468
+ return null; // Unknown name: fall back to the numeric bbox path
469
+ }
470
+
471
+ // Unwrap the chain into a contiguous frame and orient it
472
+ // north-end-first so the caps attach to the right ends
473
+ const lons = [feature[0][0]];
474
+ for (let i = 1; i < feature.length; i++) {
475
+ lons.push(unwrapLon(feature[i][0], lons[i - 1]));
476
+ }
477
+ let pts = feature.map(([, lat], i) => [lons[i], lat]);
478
+ if (pts.length > 1 && pts[0][1] < pts[pts.length - 1][1]) {
479
+ pts = [...pts].reverse();
480
+ }
481
+ pts = clipChainToLatBand(pts, minLat, maxLat);
482
+ const first = pts[0];
483
+ const last = pts[pts.length - 1];
484
+ const center = (Math.min(...lons) + Math.max(...lons)) / 2;
485
+ let openLon;
486
+ if (meridian != null) {
487
+ openLon = unwrapLon(meridian, center);
488
+ } else if (side === "west") {
489
+ // West of the front reaches across the seam: the antimeridian
490
+ // plus a margin so 170E–180E vessels stay inside the area
491
+ openLon = unwrapLon(-180, center) - 60;
492
+ } else {
493
+ openLon = unwrapLon(180, center) + 60;
494
+ }
495
+
496
+ const ring = [
497
+ [first[0], maxLat],
498
+ ...pts,
499
+ [last[0], minLat],
500
+ [openLon, minLat],
501
+ [openLon, maxLat],
502
+ ];
503
+ if (
504
+ ring[0][0] !== ring[ring.length - 1][0] ||
505
+ ring[0][1] !== ring[ring.length - 1][1]
506
+ ) {
507
+ ring.push([...ring[0]]);
508
+ }
509
+ return { type: "polygon", coordinates: ring };
510
+ }
511
+
286
512
  /**
287
513
  * Extracts the geographic geometry of a block: coordinate chain
288
514
  * first (polygon), cardinal bounds second (bbox). Front/trough
@@ -291,11 +517,16 @@ function parseCardinalBounds(text) {
291
517
  * distance the geometry carries `bufferNm` so the intersection test
292
518
  * expands to the band instead of the bare line.
293
519
  *
520
+ * Bounds referencing a named feature collected by `collectFeatures`
521
+ * ("WEST OF CF") resolve to a composed polygon; with no such feature
522
+ * known they fall back to the numeric bbox path.
523
+ *
294
524
  * @param {string} blockText
525
+ * @param {Map<string, number[][]>} [features] - `collectFeatures` map
295
526
  * @returns {{type: "polygon", coordinates: number[][], bufferNm?: number}|
296
527
  * {type: "bbox", coordinates: number[]}|null}
297
528
  */
298
- function extractGeometry(blockText) {
529
+ function extractGeometry(blockText, features = new Map()) {
299
530
  const bandMatch = blockText.match(
300
531
  /WITHIN\s+(\d+(?:\.\d+)?)\s*(?:NM|NAUTICAL\s+MILES?)\b/i,
301
532
  );
@@ -310,6 +541,10 @@ function extractGeometry(blockText) {
310
541
  ...(bufferNm != null ? { bufferNm } : {}),
311
542
  };
312
543
  }
544
+ const featurePolygon = composeFeatureBounds(blockText, features);
545
+ if (featurePolygon) {
546
+ return featurePolygon;
547
+ }
313
548
  const bbox = parseCardinalBounds(blockText);
314
549
  if (bbox) {
315
550
  return { type: "bbox", coordinates: bbox };
@@ -532,10 +767,11 @@ function filterBulletin({
532
767
  return null;
533
768
  }
534
769
  const cleaned = stripBoilerplate(rawText);
770
+ const features = collectFeatures(cleaned);
535
771
  const header = cleaned.split(/\r?\n/, 1)[0]?.trim() ?? "";
536
772
  const blocks = segmentBlocks(cleaned)
537
773
  .map((blockText) => {
538
- const geometry = extractGeometry(blockText);
774
+ const geometry = extractGeometry(blockText, features);
539
775
  if (!intersectsTrack(geometry, track)) {
540
776
  return null; // Discard rule: not on our waters
541
777
  }
@@ -732,8 +968,11 @@ module.exports = {
732
968
  segmentBlocks,
733
969
  ukhoBlocksFromWarnings,
734
970
  hemisphereDegrees,
971
+ parseCoordinatePoints,
735
972
  parseCoordinateChain,
736
973
  parseCardinalBounds,
974
+ collectFeatures,
975
+ composeFeatureBounds,
737
976
  extractGeometry,
738
977
  unwrapLon,
739
978
  ringContains,