@meri-imperiumi/signalk-passage-briefing 0.2.1 → 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 +180 -0
- package/README.md +42 -6
- package/package.json +6 -2
- package/plugin/bulletin-engine.js +250 -11
- package/plugin/celestial-source.js +7 -1
- package/plugin/index.js +170 -37
- package/plugin/weather-source.js +308 -0
- package/public/components/models.mjs +488 -48
- package/public/components/passage-outlook.js +219 -36
- package/public/components/passage-timeline.js +139 -0
- package/public/components/sk-api.js +89 -1
- package/public/components/sk-base-css.js +22 -0
- package/public/components/strategic-outlook.js +39 -105
- package/public/components/synoptic-chart.js +21 -0
- package/public/components/tactical-dashboard.js +88 -67
- package/public/icon-256.png +0 -0
- package/public/route-sim.mjs +453 -43
- package/tests/bulletin-engine.test.js +179 -0
- package/tests/plugin.test.js +316 -0
- package/tests/route-sim.test.js +326 -6
- package/tests/weather-source.test.js +272 -0
- package/tests/webapp.test.js +337 -56
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,186 @@
|
|
|
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
|
+
|
|
100
|
+
## [0.3.0] - 2026-09-28
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
|
|
104
|
+
- ETA percentile rows in the strategic outlook flag night arrivals
|
|
105
|
+
with a moon marker: arrival day/night is computed at the
|
|
106
|
+
destination for each of P10/P50/P90.
|
|
107
|
+
|
|
108
|
+
- "No sails" stretches now say why the canvas is down: `No sails -
|
|
109
|
+
drifting` when the plan drifts below the motoring wind threshold,
|
|
110
|
+
`Motoring` when the engine pushes — reconciling the sail-work
|
|
111
|
+
queue with a zero engine-hours plan (drift mode).
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
|
|
115
|
+
- Strategic outlook layout: motor hours and fuel use the shared
|
|
116
|
+
stat styling, and the sail-work queue renders as cards (tack/gybe
|
|
117
|
+
highlighted) like the tactical action queue.
|
|
118
|
+
|
|
119
|
+
- "Warnings On Your Waters" and the synoptic surface-analysis chart
|
|
120
|
+
now also appear on the tactical dashboard, not just the strategic
|
|
121
|
+
view; the chart's night palette follows the document mode via its
|
|
122
|
+
own observation (it previously never inverted when embedded).
|
|
123
|
+
Shared card/stat styles moved into the common shadow-DOM base
|
|
124
|
+
stylesheet.
|
|
125
|
+
|
|
126
|
+
- Sail-change events no longer flap when the forecast sits on a
|
|
127
|
+
matrix bin edge: a suggested state must hold through a full
|
|
128
|
+
simulation step before it enters the sail-work queue.
|
|
129
|
+
|
|
130
|
+
- Fuel is handled in liters end to end (SI — no imperial units):
|
|
131
|
+
the motor burn rate is configurable as `Motor Fuel Consumption
|
|
132
|
+
(liters per hour)` with a 1.8 l/h default, and the strategic ETA
|
|
133
|
+
table shows e.g. `72.0 l` instead of gallons.
|
|
134
|
+
|
|
135
|
+
- The tactical "Next 24 Hours" hero readout is labeled `AWS` and
|
|
136
|
+
shows its unit: `AWS 17.0 kn` instead of a bare number. (Signal K
|
|
137
|
+
carries wind in SI m/s internally; the briefing displays the
|
|
138
|
+
nautical kn.)
|
|
139
|
+
|
|
140
|
+
- Sail-change cards in the tactical dashboard and the sail-work
|
|
141
|
+
timeline in the strategic outlook render the canonical sail-state
|
|
142
|
+
keys as human-readable labels: `GENOA_1_30_FURLED_MAIN_1_REEF`
|
|
143
|
+
reads "Genoa 1 30% furled + Main 1 reef", `NO_SAILS` reads "No
|
|
144
|
+
sails". Unparseable keys still fall back to the raw form.
|
|
145
|
+
|
|
146
|
+
### Fixed
|
|
147
|
+
|
|
148
|
+
- A scheduled (oneshot/cron) refresh no longer fails forever when
|
|
149
|
+
the last briefed route has been deleted from resources: the stale
|
|
150
|
+
`last-route` pointer is removed and the refresh falls back to
|
|
151
|
+
keeping conditions-here fresh. Previously every cycle died with
|
|
152
|
+
`Briefing refresh failed (oneshot): Resource not found!`.
|
|
153
|
+
|
|
154
|
+
- The scheduled (oneshot/cron) route refresh no longer pulls the
|
|
155
|
+
bulletin stack twice per cycle: the briefing refresh already
|
|
156
|
+
fetches bulletins and synoptics for the track, so the outer
|
|
157
|
+
duplicate pass is gone.
|
|
158
|
+
|
|
159
|
+
- SWPC solar-weather timestamps are UTC but carry no offset, so
|
|
160
|
+
they were parsed in the server's local timezone: on a boat far
|
|
161
|
+
from Greenwich the entire Kp forecast window shifted and aurora
|
|
162
|
+
alerts degraded. Offsetless timestamps are now read as UTC.
|
|
163
|
+
|
|
164
|
+
- Switching the route selector between "Conditions here" and a route
|
|
165
|
+
now actually swaps the view: the tactical/strategic shell is
|
|
166
|
+
rebuilt for the served mode (previously the tabbed views never
|
|
167
|
+
came back after visiting conditions-here, so route selection
|
|
168
|
+
appeared to do nothing). Stale model data from the previous
|
|
169
|
+
selection is dropped instead of flashing.
|
|
170
|
+
|
|
171
|
+
- Selecting "Conditions here" in the route picker now actually
|
|
172
|
+
serves conditions-here: the briefing API treated an empty `route`
|
|
173
|
+
parameter as "serve the route being sailed", so the selection
|
|
174
|
+
silently returned the same route view. An explicit `?route=`
|
|
175
|
+
(even empty) now selects; only a fully omitted parameter falls
|
|
176
|
+
back to the active route.
|
|
177
|
+
|
|
178
|
+
- The webapp shows a loading state while a briefing loads or
|
|
179
|
+
refreshes (compiles can take tens of seconds on a slow link —
|
|
180
|
+
silence read as a broken app), and timed-out requests say the
|
|
181
|
+
server is busy and to retry instead of surfacing the cryptic
|
|
182
|
+
engine abort text. Rapid mode switching can no longer apply a
|
|
183
|
+
stale response after a newer one.
|
|
184
|
+
|
|
5
185
|
## [0.2.1] - 2026-09-28
|
|
6
186
|
|
|
7
187
|
## [0.2.0] - 2026-09-28
|
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
|
|
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
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Offshore passage daily briefing webapp for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"signalk-plugin-enabled-by-default": false,
|
|
27
27
|
"signalk": {
|
|
28
28
|
"displayName": "Passage Briefing",
|
|
29
|
-
"appIcon": "./icon.png",
|
|
29
|
+
"appIcon": "./icon-256.png",
|
|
30
30
|
"screenshots": [
|
|
31
31
|
"doc/here-brief.png"
|
|
32
32
|
],
|
|
@@ -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
|
-
*
|
|
171
|
-
* ring
|
|
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[][]
|
|
179
|
+
* @returns {number[][]} [[lon, lat], …] (possibly empty)
|
|
175
180
|
*/
|
|
176
|
-
function
|
|
177
|
-
const pair = /(\d+(?:\.\d+)
|
|
178
|
-
const
|
|
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
|
|
182
|
-
const
|
|
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
|
-
|
|
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,
|
|
@@ -87,7 +87,13 @@ function parseKpForecast(json, { from = new Date(), hours = 24 } = {}) {
|
|
|
87
87
|
const end = start + hours * 3600000;
|
|
88
88
|
const entries = [];
|
|
89
89
|
for (const row of json) {
|
|
90
|
-
|
|
90
|
+
// SWPC time_tag is UTC but offsetless: parsing it bare would
|
|
91
|
+
// interpret it in the server's timezone and shift the whole
|
|
92
|
+
// window (a UTC+13 boat must see the same forecast as a UTC one)
|
|
93
|
+
const raw = typeof row?.time_tag === "string" ? row.time_tag : "";
|
|
94
|
+
const t = new Date(
|
|
95
|
+
/[Zz]|[+-]\d\d:?\d\d$/.test(raw) ? raw : `${raw}Z`,
|
|
96
|
+
).getTime();
|
|
91
97
|
const kp = row?.kp;
|
|
92
98
|
if (!Number.isFinite(t) || typeof kp !== "number" || !Number.isFinite(kp)) {
|
|
93
99
|
continue;
|