@meri-imperiumi/signalk-passage-briefing 0.7.1 → 0.8.1
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 +27 -0
- package/README.md +1 -1
- package/bin/backfill-report.js +77 -50
- package/package.json +2 -3
- package/plugin/cap-source.js +12 -1
- package/plugin/celestial-ephemeris.js +54 -5
- package/plugin/fetch-engine.js +11 -1
- package/plugin/index.js +97 -50
- package/plugin/logbook-source.js +192 -56
- package/plugin/maritime-zones-source.js +47 -12
- package/plugin/satellite-source.js +32 -1
- package/plugin/weather-source.js +13 -1
- package/public/components/conditions-here.js +111 -4
- package/public/components/models.mjs +323 -32
- package/public/components/passage-outlook.js +23 -7
- package/public/components/passage-timeline.js +90 -11
- package/public/components/strategic-outlook.js +49 -0
- package/public/components/tactical-dashboard.js +73 -0
- package/public/index.html +2 -0
- package/public/route-sim.mjs +26 -1
- package/tests/cap-source.test.js +3 -0
- package/tests/celestial-ephemeris.test.js +43 -0
- package/tests/fetch-engine.test.js +12 -0
- package/tests/logbook-source.test.js +210 -58
- package/tests/maritime-zones-source.test.js +29 -3
- package/tests/plugin.test.js +127 -1
- package/tests/route-sim.test.js +31 -0
- package/tests/satellite-source.test.js +22 -0
- package/tests/weather-source.test.js +10 -0
- package/tests/webapp.test.js +333 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.1] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Branding on the webapp page (per the Signal K visuals spec): the plugin's app icon (`icon-256.png`) serves as favicon and the base canvas color ships as the `theme-color` meta, so browser chrome matches the console background in both day and night modes.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- The per-minute source-status tick no longer touches the logbook — fixes "Server unreachable: Fetch is aborted" with an empty route picker on production since 0.8.0. The checklist's logbook probe went through the resources API, whose `logentries` provider answers even a `limit: 1` listing by reading and schema-validating every day file — on a multi-year log that meant overlapping full-store scans every 60 s starving the server so `/api/status` and `/api/routes` blew the webapp's 8 s abort timeout. The logbook is a once-used learning resource (backfill), not an ongoing ingest source, so it leaves the data source statuses entirely; with no provider installed, backfill now fails loudly with the reason instead of silently reporting zero events.
|
|
12
|
+
|
|
13
|
+
## [0.8.0] - 2026-10-06
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Conditions tabs on the unified timeline (work doc #18): each entry carries a small colored tab on its left edge — the comfort tier color the tactical sparkline paints at that hour, so the passage's weather development reads at a glance. The tier comes from the simulated track's nearest hourly step; sail changes keep their own enriched conditions block as the authoritative tier, slatting renders as the sparkline's hatch stripes, and entries outside track coverage degrade to the unknown gray instead of inventing a tier.
|
|
17
|
+
- Provenance on every estimation and warning (work doc #31): a compact ↗ affordance where an external origin or on-board viewer exists, deep-linked to the specific record rather than a site front page — GDACS events link their gdacs.org event page (hazard notes on the timeline carry the same page through from the note), CAP alerts the sender's info page (feed URL as fallback), bulletin console blocks the feed rung the text arrived from, zone transitions the exact Marine Regions gazetteer record keyed by the boundary zone's MRGID (search-by-territory fallback when a source carries no MRGID; the here view's waters-you-are-in zone names link their records too), satellite passes a live tracker page keyed by the NORAD catalog number parsed from TLE line 1, and weather the on-board viewer deep link (`signalk-weather-map` webapp with `lat/lon/time/layer`, detected via the server's registered webapps — absent siblings serve no dead links) plus the external Open-Meteo forecast page rewritten to the item's own position: a simulated item links the patch of ocean the boat plans to be at, not the departure. Computed-on-board items carry no glyph: absence never implies external authority. Sky-event and official-alert rows on the conditions-here view get the same treatment.
|
|
18
|
+
- Night-darkness cloud cover (work doc #32): Open-Meteo `cloud_cover` rides the simulated track (interpolated in `weatherAt`, hourly rows and the compact `passageSummary.track`; unknown on either side of a span stays null, never blended or invented) and is stamped on every timeline item at its hour. Night entries render a ☁ beside the moon glyph, opacity scaling with cover (every night entry with a known cover carries the mark — clear nights show it faint, so an absent mark unambiguously means the source publishes none); night ETA rows in the strategic outlook get the same mark; provider mode stays honest with the moon glyph alone.
|
|
19
|
+
- Sky times on tactical and here (work doc #33): `celestialNights` entries gain `sunrise`/`sunset`/`moonrise`/`moonset` from `astronomy-engine` rise/set searches (null-degrading across polar day/night), and both views render a compact monospace sky line — sunset, nautical dusk (the briefing's night boundary), the moon's rise/set within the night window, and the now cloud cover from the first forecast step — so watch planning reads off one line instead of guesswork around dusk. Also fixed the conditions-here CAP empty state early-returning before the waters-you-are-in card could render.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- The briefing refresh button reads "Update now" instead of "Fetch now" (matching loading strips "Updating briefing…" / "Updating conditions…"): from the crew's side the action regenerates the briefing with fresh weather — the fetching is the plugin's business, not the button's.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- The critical-hazard banner rendered always — an empty red strip with an ACK button above the webapp header even with no active alerts: the banner stylesheet's `display: flex` beats the browser's built-in `[hidden] { display: none }` regardless of specificity, so the `hidden` attribute did nothing (the `.tab-bar` already carried the `[hidden]` antidote; the banner and the embed-mode header now do too). With the bar permanently visible but empty, ACK had nothing to acknowledge. Also fixed red-on-red text: the banner painted message and background with the same `--color-red`, so a real emergency's message would have been invisible — text now uses the base canvas color per the codebase's text-on-color pattern.
|
|
26
|
+
- CAP alerts whose `<instruction>` carries a bare placeholder — PTWC ships a literal "N/A" on its tsunami information statements — rendered "N/A" as the crew's action line; the parser now treats the placeholder as no instruction, and the Official Alerts card renders no action row for them.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Logbook reading moved from signalk-logbook's on-disk YAML store to the Signal K v2 Resources API: the plugin now consumes the `logentries` resource type (the contract signalk-logbook documents in `docs/logentries-resource.md` and serves as its reference provider) through `app.resourcesApi` — in-process, no tokens, no filesystem coupling to another plugin's storage layout. The reader normalizes the resource representation at one boundary (`navigation.position` pathvalue for position, `environment.wind.speedOverGround` m/s → kn and `environment.wind.directionTrue` rad → deg for the wind snapshots); the sail-text parsing and event classification are unchanged. The source-status checklist row probes the resource type instead of the store directory. `bin/backfill-report.js` reads over the REST surface (`--url`/`--token`, a `readonly` token suffices) instead of a local YAML copy — usable ashore against any reachable server. The `yaml` dependency is dropped.
|
|
31
|
+
|
|
5
32
|
## [0.7.1] - 2026-10-05
|
|
6
33
|
|
|
7
34
|
### Fixed
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-p
|
|
|
28
28
|
- Resources API — route geometries and polar tables
|
|
29
29
|
- History API (on board) — wind/attempt snapshots for the sail-event backfill
|
|
30
30
|
- Published tile paths — `navigation.briefing.generatedAt` / `.route` / `.hasNew` / `.comfort` drive the plotter-extension tile (`.acknowledgedAt` is writable to clear the NEW badge)
|
|
31
|
-
- [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook)
|
|
31
|
+
- `logentries` resource API (for example [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook)) — crewed sail events (reefs, sail changes) that train the preference matrix; any provider implementing the resource contract works
|
|
32
32
|
- [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time) — `environment.time.timezoneOffset` / `.timezoneRegion`, the vessel's published timezone: briefing stamps render in ship's time when available (the offset rides on every stamp), falling back to UTC `Z` when nothing is published
|
|
33
33
|
- [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and `watch.schedule`: the running watch rotation. While a watch is running, planned sail changes anchor to the previous watch handover (both teams awake) instead of sunrise/sunset; boundaries are extrapolated across the forecast horizon from the rotation cycle
|
|
34
34
|
- [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration) — sail inventory used to filter free-text noise out of log entries
|
package/bin/backfill-report.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Logbook backfill & sail usage report.
|
|
4
4
|
*
|
|
5
|
-
* Runs the SPEC §4.2 sail preference backfill over
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Runs the SPEC §4.2 sail preference backfill over the logbook
|
|
6
|
+
* entries served by a Signal K server's `logentries` resource (the
|
|
7
|
+
* signalk-logbook contract), using the wind snapshots written in the
|
|
8
|
+
* log entries (no History API needed), then reports:
|
|
9
9
|
*
|
|
10
10
|
* 1. the learned sail preference matrix (SPEC §3.2);
|
|
11
11
|
* 2. the conditions actually experienced per sail combination, next
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Usage:
|
|
17
17
|
*
|
|
18
|
-
* node bin/backfill-report.js [--
|
|
19
|
-
* [--from <iso>] [--to <iso>] [--db <sqlite-file>]
|
|
18
|
+
* node bin/backfill-report.js [--url <server>] [--token <jwt>]
|
|
19
|
+
* [--sails <json>] [--from <iso>] [--to <iso>] [--db <sqlite-file>]
|
|
20
|
+
* [--json]
|
|
20
21
|
*
|
|
21
|
-
* Defaults: `--
|
|
22
|
-
* `--sails
|
|
22
|
+
* Defaults: `--url http://localhost:3000` (a `readonly` token is
|
|
23
|
+
* enough for reads), `--sails
|
|
24
|
+
* ~/.signalk/plugin-config-data/sailsconfiguration.json`.
|
|
23
25
|
*
|
|
24
26
|
* Once on board, re-run the backfill through the plugin's
|
|
25
27
|
* `POST /api/backfill?source=history&baseUrl=<server>` route to learn
|
|
@@ -34,7 +36,7 @@ const { join } = require("node:path");
|
|
|
34
36
|
|
|
35
37
|
const { PassageDatabase } = require("../plugin/sqlite-db.js");
|
|
36
38
|
const {
|
|
37
|
-
|
|
39
|
+
readLogbookEntriesRest,
|
|
38
40
|
readLogbookSailEvents,
|
|
39
41
|
} = require("../plugin/logbook-source.js");
|
|
40
42
|
const {
|
|
@@ -49,6 +51,11 @@ const {
|
|
|
49
51
|
|
|
50
52
|
const SIGNALK_DIR = join(homedir(), ".signalk", "plugin-config-data");
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Server to read the logbook from when `--url` is not given.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_SERVER_URL = "http://localhost:3000";
|
|
58
|
+
|
|
52
59
|
/**
|
|
53
60
|
* Parses argv into a `{flag: value}` map (`--flag value`, boolean
|
|
54
61
|
* flags without value become true).
|
|
@@ -77,10 +84,9 @@ function parseArgs(argv) {
|
|
|
77
84
|
|
|
78
85
|
function main() {
|
|
79
86
|
const args = parseArgs(process.argv.slice(2));
|
|
80
|
-
const
|
|
81
|
-
typeof args.
|
|
82
|
-
|
|
83
|
-
: join(SIGNALK_DIR, "signalk-logbook");
|
|
87
|
+
const serverUrl =
|
|
88
|
+
typeof args.url === "string" ? args.url : DEFAULT_SERVER_URL;
|
|
89
|
+
const token = typeof args.token === "string" ? args.token : undefined;
|
|
84
90
|
const sailsFile =
|
|
85
91
|
typeof args.sails === "string"
|
|
86
92
|
? args.sails
|
|
@@ -94,54 +100,75 @@ function main() {
|
|
|
94
100
|
);
|
|
95
101
|
const db = new PassageDatabase(dataDir);
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
sails.length > 0
|
|
104
|
-
? new Set(sails.map((sail) => sail.nameKey))
|
|
105
|
-
: undefined;
|
|
106
|
-
const events = await readLogbookSailEvents({
|
|
107
|
-
dir: logbookDir,
|
|
103
|
+
(async () => {
|
|
104
|
+
try {
|
|
105
|
+
const report = await collectReport({
|
|
106
|
+
serverUrl,
|
|
107
|
+
token,
|
|
108
|
+
sailsFile,
|
|
108
109
|
from,
|
|
109
110
|
to,
|
|
110
|
-
knownSailKeys,
|
|
111
|
-
});
|
|
112
|
-
const summary = await backfillSailEvents({
|
|
113
111
|
db,
|
|
114
|
-
events,
|
|
115
|
-
getWindStats: createLogbookWindStats(entries),
|
|
116
112
|
});
|
|
117
|
-
const matrix = db.getSailPreferenceMatrix();
|
|
118
|
-
const usage = summarizeSailUsage(
|
|
119
|
-
db.getSailEvents({ limit: 10000 }),
|
|
120
|
-
db.getWindHistory("0000-01-01T00:00:00Z", "9999-12-31T23:59:59Z"),
|
|
121
|
-
);
|
|
122
|
-
return {
|
|
123
|
-
logbook: logbookDir,
|
|
124
|
-
entries: entries.length,
|
|
125
|
-
events: events.length,
|
|
126
|
-
summary,
|
|
127
|
-
matrix,
|
|
128
|
-
usage,
|
|
129
|
-
sails,
|
|
130
|
-
};
|
|
131
|
-
})
|
|
132
|
-
.then((report) => {
|
|
133
|
-
db.close();
|
|
134
113
|
if (asJson) {
|
|
135
114
|
console.log(JSON.stringify(report, null, 2));
|
|
136
115
|
return;
|
|
137
116
|
}
|
|
138
117
|
printReport(report);
|
|
139
|
-
})
|
|
140
|
-
.catch((error) => {
|
|
141
|
-
db.close();
|
|
118
|
+
} catch (error) {
|
|
142
119
|
console.error(`backfill-report: ${error.message}`);
|
|
143
120
|
process.exitCode = 1;
|
|
144
|
-
}
|
|
121
|
+
} finally {
|
|
122
|
+
db.close();
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reads the logbook over the resources API and runs the backfill.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} options
|
|
131
|
+
* @param {string} options.serverUrl - Signal K server base URL
|
|
132
|
+
* @param {string|undefined} options.token - Bearer token for reads
|
|
133
|
+
* @param {string} options.sailsFile - Sails configuration JSON path
|
|
134
|
+
* @param {string|undefined} options.from - Event window start
|
|
135
|
+
* @param {string|undefined} options.to - Event window end
|
|
136
|
+
* @param {object} options.db - Passage database
|
|
137
|
+
* @returns {Promise<object>} Report for printReport/JSON output
|
|
138
|
+
*/
|
|
139
|
+
async function collectReport({ serverUrl, token, sailsFile, from, to, db }) {
|
|
140
|
+
const entries = await readLogbookEntriesRest({
|
|
141
|
+
baseUrl: serverUrl,
|
|
142
|
+
token,
|
|
143
|
+
});
|
|
144
|
+
const sails = await readSailsConfiguration(sailsFile);
|
|
145
|
+
const knownSailKeys =
|
|
146
|
+
sails.length > 0 ? new Set(sails.map((sail) => sail.nameKey)) : undefined;
|
|
147
|
+
const events = await readLogbookSailEvents({
|
|
148
|
+
entries,
|
|
149
|
+
from,
|
|
150
|
+
to,
|
|
151
|
+
knownSailKeys,
|
|
152
|
+
});
|
|
153
|
+
const summary = await backfillSailEvents({
|
|
154
|
+
db,
|
|
155
|
+
events,
|
|
156
|
+
getWindStats: createLogbookWindStats(entries),
|
|
157
|
+
});
|
|
158
|
+
const matrix = db.getSailPreferenceMatrix();
|
|
159
|
+
const usage = summarizeSailUsage(
|
|
160
|
+
db.getSailEvents({ limit: 10000 }),
|
|
161
|
+
db.getWindHistory("0000-01-01T00:00:00Z", "9999-12-31T23:59:59Z"),
|
|
162
|
+
);
|
|
163
|
+
return {
|
|
164
|
+
logbook: serverUrl,
|
|
165
|
+
entries: entries.length,
|
|
166
|
+
events: events.length,
|
|
167
|
+
summary,
|
|
168
|
+
matrix,
|
|
169
|
+
usage,
|
|
170
|
+
sails,
|
|
171
|
+
};
|
|
145
172
|
}
|
|
146
173
|
|
|
147
174
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-passage-briefing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Offshore passage daily briefing webapp for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -46,8 +46,7 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@openwaters/maritime-zones": "^0.2.1",
|
|
48
48
|
"astronomy-engine": "^2.1.19",
|
|
49
|
-
"satellite.js": "^7.1.0"
|
|
50
|
-
"yaml": "^2.9.0"
|
|
49
|
+
"satellite.js": "^7.1.0"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
52
|
"@signalk/server-api": "^2.30.0"
|
package/plugin/cap-source.js
CHANGED
|
@@ -27,6 +27,13 @@ const { tagValue, unescapeXml } = require("./hazard-source.js");
|
|
|
27
27
|
/** Severity ranking for CAP's own vocabulary. */
|
|
28
28
|
const SEVERITY_RANK = { extreme: 3, severe: 2, moderate: 1, minor: 0 };
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Instruction placeholders some senders ship (PTWC's CAP documents
|
|
32
|
+
* carry a literal "N/A"): rendered as the crew's action, so a bare
|
|
33
|
+
* placeholder is no instruction at all.
|
|
34
|
+
*/
|
|
35
|
+
const INSTRUCTION_PLACEHOLDER = /^n\/?a\.?$/i;
|
|
36
|
+
|
|
30
37
|
/** Default feed: the NWS Pacific tsunami warning center. */
|
|
31
38
|
const CAP_DEFAULT_URL = "https://www.tsunami.gov/events/xml/PHEBCAP.xml";
|
|
32
39
|
|
|
@@ -218,6 +225,7 @@ function parseCapAlert(xml, { sourceUrl = null, raw = null } = {}) {
|
|
|
218
225
|
const info = pickInfoBlock(xml) ?? "";
|
|
219
226
|
const expires = capText(info, "expires") || null;
|
|
220
227
|
const geometry = infoGeometry(info);
|
|
228
|
+
const instruction = capText(info, "instruction") || null;
|
|
221
229
|
return {
|
|
222
230
|
id: `${identifier}|${sent ?? ""}`,
|
|
223
231
|
identifier,
|
|
@@ -232,7 +240,10 @@ function parseCapAlert(xml, { sourceUrl = null, raw = null } = {}) {
|
|
|
232
240
|
expires,
|
|
233
241
|
headline: capText(info, "headline") || null,
|
|
234
242
|
description: capText(info, "description") || null,
|
|
235
|
-
instruction:
|
|
243
|
+
instruction:
|
|
244
|
+
instruction && !INSTRUCTION_PLACEHOLDER.test(instruction)
|
|
245
|
+
? instruction
|
|
246
|
+
: null,
|
|
236
247
|
web: capText(info, "web") || null,
|
|
237
248
|
areaDesc: capText(info, "areaDesc") || null,
|
|
238
249
|
geometry,
|
|
@@ -238,10 +238,19 @@ function twilightTimes(date, lat, lon) {
|
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
240
|
* Daily night context for the briefing payload (`celestialNights`):
|
|
241
|
-
* one entry per day with the twilight times of that night
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* of re-deriving astronomy in the browser.
|
|
241
|
+
* one entry per day with the twilight times of that night, the moon
|
|
242
|
+
* phase/illumination at nightfall, and the sun/moon rise and set
|
|
243
|
+
* stamps bracketing the night (work doc #33 — the sky line reads
|
|
244
|
+
* these instead of re-deriving astronomy in the browser). The
|
|
245
|
+
* timeline's night glyph and any future "when is it really dark"
|
|
246
|
+
* display read this instead of re-deriving astronomy in the browser.
|
|
247
|
+
*
|
|
248
|
+
* Rise/set search semantics: sunset is the setting crossing before
|
|
249
|
+
* the night's civil dusk, sunrise the rising crossing after the dusk
|
|
250
|
+
* anchor; moonrise/moonset are the moon's next crossings after dusk
|
|
251
|
+
* (a moon already up at dusk reports tomorrow's rise — the set stamp
|
|
252
|
+
* is tonight's). Polar day/night and missing crossings degrade to
|
|
253
|
+
* null like the twilight bounds.
|
|
245
254
|
*
|
|
246
255
|
* @param {object} params
|
|
247
256
|
* @param {Date} params.from - Window start
|
|
@@ -252,21 +261,61 @@ function twilightTimes(date, lat, lon) {
|
|
|
252
261
|
* moonIllumination: number, civilDusk: string|null,
|
|
253
262
|
* nauticalDusk: string|null, astronomicalDusk: string|null,
|
|
254
263
|
* astronomicalDawn: string|null, nauticalDawn: string|null,
|
|
255
|
-
* civilDawn: string|null
|
|
264
|
+
* civilDawn: string|null, sunrise: string|null, sunset: string|null,
|
|
265
|
+
* moonrise: string|null, moonset: string|null}>}
|
|
256
266
|
*/
|
|
257
267
|
function celestialNights({ from, days = 8, lat, lon }) {
|
|
258
268
|
const nights = [];
|
|
269
|
+
const observer = observerAt(lat, lon);
|
|
270
|
+
// First rise/set crossing of a body after `start`; missing
|
|
271
|
+
// crossings (polar day/night, no crossing within the window)
|
|
272
|
+
// degrade to null
|
|
273
|
+
const riseSet = (body, direction, start) => {
|
|
274
|
+
try {
|
|
275
|
+
const found = A.SearchRiseSet(
|
|
276
|
+
body,
|
|
277
|
+
observer,
|
|
278
|
+
direction,
|
|
279
|
+
A.MakeTime(start),
|
|
280
|
+
1.5,
|
|
281
|
+
);
|
|
282
|
+
return found ? found.date.toISOString() : null;
|
|
283
|
+
} catch (_error) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
259
287
|
for (let d = 0; d < days; d++) {
|
|
260
288
|
const base = new Date(from.getTime() + d * 86400000);
|
|
261
289
|
const twilights = twilightTimes(base, lat, lon);
|
|
262
290
|
const anchor = twilights.nauticalDusk
|
|
263
291
|
? new Date(twilights.nauticalDusk)
|
|
264
292
|
: base;
|
|
293
|
+
// Sunset: the setting crossing before this night's civil dusk —
|
|
294
|
+
// search from a day back so the search's forward window covers it
|
|
295
|
+
const civilDuskMs = twilights.civilDusk
|
|
296
|
+
? new Date(twilights.civilDusk).getTime()
|
|
297
|
+
: null;
|
|
298
|
+
const sunset = civilDuskMs
|
|
299
|
+
? riseSet(A.Body.Sun, -1, new Date(civilDuskMs - 18 * 3600000))
|
|
300
|
+
: null;
|
|
301
|
+
// Dawn side: the next rising after the dusk anchor (the same
|
|
302
|
+
// night's sunrise); the +2 min skips the numerical tangency at
|
|
303
|
+
// the crossing itself, as the dawn chain does
|
|
304
|
+
const afterDusk = new Date(anchor.getTime() + 120000);
|
|
305
|
+
const sunrise = riseSet(A.Body.Sun, 1, afterDusk);
|
|
306
|
+
// Moon brackets (work docs #32/#33): the next set and rise after
|
|
307
|
+
// dusk — a moon already up at dusk reports tomorrow's rise
|
|
308
|
+
const moonset = riseSet(A.Body.Moon, -1, afterDusk);
|
|
309
|
+
const moonrise = riseSet(A.Body.Moon, 1, afterDusk);
|
|
265
310
|
nights.push({
|
|
266
311
|
timestamp: anchor.toISOString(),
|
|
267
312
|
moonPhaseDeg: Math.round(moonPhaseDeg(anchor) * 10) / 10,
|
|
268
313
|
moonIllumination: Math.round(moonIllumination(anchor) * 1000) / 1000,
|
|
269
314
|
...twilights,
|
|
315
|
+
sunset,
|
|
316
|
+
sunrise,
|
|
317
|
+
moonrise,
|
|
318
|
+
moonset,
|
|
270
319
|
});
|
|
271
320
|
}
|
|
272
321
|
return nights;
|
package/plugin/fetch-engine.js
CHANGED
|
@@ -564,11 +564,21 @@ async function fetchWeatherAlongTrack({
|
|
|
564
564
|
if (currentsResponse != null) {
|
|
565
565
|
models.push("marine:smoc_currents");
|
|
566
566
|
}
|
|
567
|
+
const fetchedAt = new Date().toISOString();
|
|
567
568
|
return {
|
|
568
569
|
metadata: {
|
|
569
|
-
fetchedAt
|
|
570
|
+
fetchedAt,
|
|
570
571
|
source: "api",
|
|
571
572
|
models,
|
|
573
|
+
// Where this forecast came from (work doc #31): the canonical
|
|
574
|
+
// per-position Open-Meteo forecast page for the first waypoint
|
|
575
|
+
provenance: {
|
|
576
|
+
kind: "forecast",
|
|
577
|
+
label: `Open-Meteo · ${models.join(", ")}`,
|
|
578
|
+
url: `https://open-meteo.com/en/docs?latitude=${waypoints[0].lat}&longitude=${waypoints[0].lon}`,
|
|
579
|
+
viewerBase: null,
|
|
580
|
+
at: fetchedAt,
|
|
581
|
+
},
|
|
572
582
|
},
|
|
573
583
|
waypoints: payloadWaypoints,
|
|
574
584
|
};
|
package/plugin/index.js
CHANGED
|
@@ -20,13 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const { join } = require("node:path");
|
|
22
22
|
const { homedir } = require("node:os");
|
|
23
|
-
const {
|
|
24
|
-
mkdir,
|
|
25
|
-
readdir,
|
|
26
|
-
readFile,
|
|
27
|
-
unlink,
|
|
28
|
-
writeFile,
|
|
29
|
-
} = require("node:fs/promises");
|
|
23
|
+
const { mkdir, readFile, unlink, writeFile } = require("node:fs/promises");
|
|
30
24
|
|
|
31
25
|
const { PassageStateMachine } = require("./state-machine.js");
|
|
32
26
|
const { PassageDatabase } = require("./sqlite-db.js");
|
|
@@ -533,11 +527,19 @@ module.exports = (app) => {
|
|
|
533
527
|
/** Filtered blocks for one cache entry against a track: structured
|
|
534
528
|
* UKHO JSON entries skip the text pipeline entirely. */
|
|
535
529
|
function blocksForEntry(entry, track) {
|
|
530
|
+
// Provenance (work doc #31): the feed URL the text arrived from,
|
|
531
|
+
// stamped on every surviving block — the spool path has a file
|
|
532
|
+
// path, not a URL, and carries no provenance
|
|
533
|
+
const sourceUrl = /^https?:\/\//.test(entry.url ?? "") ? entry.url : null;
|
|
534
|
+
const withProvenance = (blocks) =>
|
|
535
|
+
sourceUrl ? blocks.map((block) => ({ ...block, sourceUrl })) : blocks;
|
|
536
536
|
if (entry.format === "ukho-json") {
|
|
537
537
|
try {
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
|
|
538
|
+
return withProvenance(
|
|
539
|
+
ukhoBlocksFromWarnings(
|
|
540
|
+
parseUkhoWarnings(JSON.parse(entry.text)),
|
|
541
|
+
track,
|
|
542
|
+
),
|
|
541
543
|
);
|
|
542
544
|
} catch {
|
|
543
545
|
return []; // Corrupted entry: skip, other entries still serve
|
|
@@ -549,7 +551,7 @@ module.exports = (app) => {
|
|
|
549
551
|
track,
|
|
550
552
|
issuedAt: entry.fetchedAt,
|
|
551
553
|
});
|
|
552
|
-
return bulletin?.blocks ?? [];
|
|
554
|
+
return withProvenance(bulletin?.blocks ?? []);
|
|
553
555
|
}
|
|
554
556
|
|
|
555
557
|
/** Bulletin-level metadata (header/issuedAt/source/raw text) for
|
|
@@ -830,6 +832,42 @@ module.exports = (app) => {
|
|
|
830
832
|
* samples (route mode) or the position waypoint (here mode)
|
|
831
833
|
* @returns {Promise<void>}
|
|
832
834
|
*/
|
|
835
|
+
/**
|
|
836
|
+
* On-board viewer base for weather provenance (work doc #31): the
|
|
837
|
+
* weather-map sibling webapp renders the same fields the briefing
|
|
838
|
+
* derives from, reachable over the LAN underway. The server knows
|
|
839
|
+
* its registered webapps; a link to an absent sibling is dropped
|
|
840
|
+
* rather than served dead. Deep-link parameters (lat/lon/time/
|
|
841
|
+
* layer) are appended per item by the webapp.
|
|
842
|
+
*
|
|
843
|
+
* @returns {string|null} Viewer base path when installed
|
|
844
|
+
*/
|
|
845
|
+
function weatherViewerBase() {
|
|
846
|
+
const webapps = app.webapps;
|
|
847
|
+
if (!Array.isArray(webapps)) {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
return webapps.some((webapp) => webapp?.name === "signalk-weather-map")
|
|
851
|
+
? "/signalk-weather-map/"
|
|
852
|
+
: null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Stamps the on-board viewer link onto a payload's weather
|
|
857
|
+
* provenance record (work doc #31). Payloads whose cache predates
|
|
858
|
+
* the provenance field keep no record — the UI degrades to no
|
|
859
|
+
* glyph, never a dead link.
|
|
860
|
+
*
|
|
861
|
+
* @param {object} payload - Briefing payload, mutated in place
|
|
862
|
+
* @returns {void}
|
|
863
|
+
*/
|
|
864
|
+
function attachWeatherViewer(payload) {
|
|
865
|
+
const base = weatherViewerBase();
|
|
866
|
+
if (base && payload?.metadata?.provenance) {
|
|
867
|
+
payload.metadata.provenance.viewerBase = base;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
833
871
|
async function attachHazardEvents(payload, vessel, waypoints) {
|
|
834
872
|
if (!hazardEventsEnabled) {
|
|
835
873
|
return;
|
|
@@ -854,7 +892,22 @@ module.exports = (app) => {
|
|
|
854
892
|
...hazardFilterConfig,
|
|
855
893
|
now: new Date(),
|
|
856
894
|
});
|
|
857
|
-
|
|
895
|
+
// Provenance (work doc #31): the GDACS event page rides each
|
|
896
|
+
// surviving event — one tap from timeline row to origin
|
|
897
|
+
payload.hazardEvents = events.map((event) =>
|
|
898
|
+
event.link
|
|
899
|
+
? {
|
|
900
|
+
...event,
|
|
901
|
+
provenance: {
|
|
902
|
+
kind: "warning",
|
|
903
|
+
label: "GDACS",
|
|
904
|
+
url: event.link,
|
|
905
|
+
viewerUrl: null,
|
|
906
|
+
at: event.timestamp ?? null,
|
|
907
|
+
},
|
|
908
|
+
}
|
|
909
|
+
: event,
|
|
910
|
+
);
|
|
858
911
|
if (notesStore) {
|
|
859
912
|
const result = await publishHazardNotes({ store: notesStore, events });
|
|
860
913
|
if (result.published.length > 0 || result.pruned.length > 0) {
|
|
@@ -954,12 +1007,33 @@ module.exports = (app) => {
|
|
|
954
1007
|
}
|
|
955
1008
|
const events =
|
|
956
1009
|
result?.events ?? (await capSource.loadCapAlerts(app.getDataDirPath()));
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1010
|
+
// Provenance (work doc #31): the sender's info page when
|
|
1011
|
+
// published, else the feed the document arrived from. Stamped on
|
|
1012
|
+
// the FILTERED survivors — stamping the raw cache instead would
|
|
1013
|
+
// bypass the severity, expiry and geography gates (a Puerto Rico
|
|
1014
|
+
// information statement reached a Tonga vessel that way once).
|
|
1015
|
+
payload.capAlerts = capSource
|
|
1016
|
+
.filterCapAlerts({
|
|
1017
|
+
events,
|
|
1018
|
+
track: waypoints.map((w) => [w.lon, w.lat]),
|
|
1019
|
+
minSeverity: capMinSeverity,
|
|
1020
|
+
now: new Date(),
|
|
1021
|
+
})
|
|
1022
|
+
.map((alert) => {
|
|
1023
|
+
const url = alert.web ?? alert.sourceUrl ?? null;
|
|
1024
|
+
return url
|
|
1025
|
+
? {
|
|
1026
|
+
...alert,
|
|
1027
|
+
provenance: {
|
|
1028
|
+
kind: "warning",
|
|
1029
|
+
label: alert.web ? "Official alert page" : "CAP feed",
|
|
1030
|
+
url,
|
|
1031
|
+
viewerUrl: null,
|
|
1032
|
+
at: alert.sent ?? null,
|
|
1033
|
+
},
|
|
1034
|
+
}
|
|
1035
|
+
: alert;
|
|
1036
|
+
});
|
|
963
1037
|
if (notesStore) {
|
|
964
1038
|
const result = await publishCapAlertNotes({
|
|
965
1039
|
store: notesStore,
|
|
@@ -1045,6 +1119,7 @@ module.exports = (app) => {
|
|
|
1045
1119
|
}),
|
|
1046
1120
|
);
|
|
1047
1121
|
payload.metadata.mode = "here";
|
|
1122
|
+
attachWeatherViewer(payload);
|
|
1048
1123
|
const bulletin = await bulletinForTrack(waypoints);
|
|
1049
1124
|
if (bulletin) {
|
|
1050
1125
|
payload.metareaBulletin = bulletin;
|
|
@@ -1208,6 +1283,7 @@ module.exports = (app) => {
|
|
|
1208
1283
|
const payload = await withSourceStatus(WEATHER_SOURCE, () =>
|
|
1209
1284
|
trackWeatherFetcher({ waypoints, forecastDays }),
|
|
1210
1285
|
);
|
|
1286
|
+
attachWeatherViewer(payload);
|
|
1211
1287
|
const bulletin = await bulletinForTrack(waypoints);
|
|
1212
1288
|
if (bulletin) {
|
|
1213
1289
|
payload.metareaBulletin = bulletin;
|
|
@@ -1782,18 +1858,6 @@ module.exports = (app) => {
|
|
|
1782
1858
|
}
|
|
1783
1859
|
}
|
|
1784
1860
|
|
|
1785
|
-
/**
|
|
1786
|
-
* Location of the signalk-logbook on-disk store. When the Signal K
|
|
1787
|
-
* Resource API grows logbook support, this (and the logbook-source
|
|
1788
|
-
* module behind it) is the swap point.
|
|
1789
|
-
*
|
|
1790
|
-
* @returns {string}
|
|
1791
|
-
*/
|
|
1792
|
-
function logbookStoreDir() {
|
|
1793
|
-
const configPath = app.config?.configPath ?? app.dataDir;
|
|
1794
|
-
return join(configPath, "plugin-config-data", "signalk-logbook");
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
1861
|
/** Startup grace (ms) before the delta-fed path sources are
|
|
1798
1862
|
* recorded in the status registry (work doc #23, bergie session
|
|
1799
1863
|
* note): the server replays current values to new subscriptions a
|
|
@@ -1932,21 +1996,6 @@ module.exports = (app) => {
|
|
|
1932
1996
|
now,
|
|
1933
1997
|
});
|
|
1934
1998
|
}
|
|
1935
|
-
let logbookReadable = false;
|
|
1936
|
-
try {
|
|
1937
|
-
await readdir(logbookStoreDir());
|
|
1938
|
-
logbookReadable = true;
|
|
1939
|
-
} catch (_error) {
|
|
1940
|
-
logbookReadable = false;
|
|
1941
|
-
}
|
|
1942
|
-
sourceStatus.record({
|
|
1943
|
-
id: "sk-logbook",
|
|
1944
|
-
label: "Logbook (signalk-logbook)",
|
|
1945
|
-
kind: "signalk",
|
|
1946
|
-
url: logbookStoreDir(),
|
|
1947
|
-
...(logbookReadable ? {} : { absent: true }),
|
|
1948
|
-
now,
|
|
1949
|
-
});
|
|
1950
1999
|
}
|
|
1951
2000
|
|
|
1952
2001
|
/**
|
|
@@ -2440,7 +2489,7 @@ module.exports = (app) => {
|
|
|
2440
2489
|
router.get("/api/logbook-events", async (req, res) => {
|
|
2441
2490
|
try {
|
|
2442
2491
|
const events = await readLogbookSailEvents({
|
|
2443
|
-
|
|
2492
|
+
app,
|
|
2444
2493
|
from:
|
|
2445
2494
|
typeof req.query.from === "string" ? req.query.from : undefined,
|
|
2446
2495
|
to: typeof req.query.to === "string" ? req.query.to : undefined,
|
|
@@ -2700,7 +2749,7 @@ module.exports = (app) => {
|
|
|
2700
2749
|
const to =
|
|
2701
2750
|
typeof req.query.to === "string" ? req.query.to : undefined;
|
|
2702
2751
|
const events = await readLogbookSailEvents({
|
|
2703
|
-
|
|
2752
|
+
app,
|
|
2704
2753
|
from,
|
|
2705
2754
|
to,
|
|
2706
2755
|
knownSailKeys: await knownSailKeys(),
|
|
@@ -2709,9 +2758,7 @@ module.exports = (app) => {
|
|
|
2709
2758
|
req.query.source === "history" &&
|
|
2710
2759
|
typeof req.query.baseUrl === "string"
|
|
2711
2760
|
? createHistoryWindStats({ baseUrl: req.query.baseUrl })
|
|
2712
|
-
: createLogbookWindStats(
|
|
2713
|
-
await readLogbookEntries(logbookStoreDir()),
|
|
2714
|
-
);
|
|
2761
|
+
: createLogbookWindStats(await readLogbookEntries(app));
|
|
2715
2762
|
const summary = await backfillSailEvents({
|
|
2716
2763
|
db,
|
|
2717
2764
|
events,
|