@meri-imperiumi/signalk-passage-briefing 0.7.0 → 0.8.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 +24 -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 +100 -44
- package/plugin/logbook-source.js +208 -56
- package/plugin/maritime-zones-source.js +47 -12
- package/plugin/notes-store.js +7 -2
- 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/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 +202 -59
- package/tests/maritime-zones-source.test.js +29 -3
- package/tests/notes-store.test.js +10 -0
- package/tests/plugin.test.js +127 -0
- 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,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.0] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
## [0.7.1] - 2026-10-05
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Notes resource `bbox` queries crossing the antimeridian now match notes on both sides of 180° (`plugin/notes-store.js`): a west edge east of the east edge — the natural way to frame a Pacific box around the seam — previously matched nothing, since no single longitude is simultaneously ≥ 170 and ≤ -175. Such a box now wraps the seam (matching west of the east edge or east of the west edge); regular west≤east boxes behave exactly as before
|
|
28
|
+
|
|
5
29
|
## [0.7.0] - 2026-10-04
|
|
6
30
|
|
|
7
31
|
### Added
|
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.0",
|
|
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,17 +20,12 @@
|
|
|
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");
|
|
33
27
|
const {
|
|
28
|
+
logbookAvailable,
|
|
34
29
|
readLogbookEntries,
|
|
35
30
|
readLogbookSailEvents,
|
|
36
31
|
} = require("./logbook-source.js");
|
|
@@ -533,11 +528,19 @@ module.exports = (app) => {
|
|
|
533
528
|
/** Filtered blocks for one cache entry against a track: structured
|
|
534
529
|
* UKHO JSON entries skip the text pipeline entirely. */
|
|
535
530
|
function blocksForEntry(entry, track) {
|
|
531
|
+
// Provenance (work doc #31): the feed URL the text arrived from,
|
|
532
|
+
// stamped on every surviving block — the spool path has a file
|
|
533
|
+
// path, not a URL, and carries no provenance
|
|
534
|
+
const sourceUrl = /^https?:\/\//.test(entry.url ?? "") ? entry.url : null;
|
|
535
|
+
const withProvenance = (blocks) =>
|
|
536
|
+
sourceUrl ? blocks.map((block) => ({ ...block, sourceUrl })) : blocks;
|
|
536
537
|
if (entry.format === "ukho-json") {
|
|
537
538
|
try {
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
return withProvenance(
|
|
540
|
+
ukhoBlocksFromWarnings(
|
|
541
|
+
parseUkhoWarnings(JSON.parse(entry.text)),
|
|
542
|
+
track,
|
|
543
|
+
),
|
|
541
544
|
);
|
|
542
545
|
} catch {
|
|
543
546
|
return []; // Corrupted entry: skip, other entries still serve
|
|
@@ -549,7 +552,7 @@ module.exports = (app) => {
|
|
|
549
552
|
track,
|
|
550
553
|
issuedAt: entry.fetchedAt,
|
|
551
554
|
});
|
|
552
|
-
return bulletin?.blocks ?? [];
|
|
555
|
+
return withProvenance(bulletin?.blocks ?? []);
|
|
553
556
|
}
|
|
554
557
|
|
|
555
558
|
/** Bulletin-level metadata (header/issuedAt/source/raw text) for
|
|
@@ -830,6 +833,42 @@ module.exports = (app) => {
|
|
|
830
833
|
* samples (route mode) or the position waypoint (here mode)
|
|
831
834
|
* @returns {Promise<void>}
|
|
832
835
|
*/
|
|
836
|
+
/**
|
|
837
|
+
* On-board viewer base for weather provenance (work doc #31): the
|
|
838
|
+
* weather-map sibling webapp renders the same fields the briefing
|
|
839
|
+
* derives from, reachable over the LAN underway. The server knows
|
|
840
|
+
* its registered webapps; a link to an absent sibling is dropped
|
|
841
|
+
* rather than served dead. Deep-link parameters (lat/lon/time/
|
|
842
|
+
* layer) are appended per item by the webapp.
|
|
843
|
+
*
|
|
844
|
+
* @returns {string|null} Viewer base path when installed
|
|
845
|
+
*/
|
|
846
|
+
function weatherViewerBase() {
|
|
847
|
+
const webapps = app.webapps;
|
|
848
|
+
if (!Array.isArray(webapps)) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
return webapps.some((webapp) => webapp?.name === "signalk-weather-map")
|
|
852
|
+
? "/signalk-weather-map/"
|
|
853
|
+
: null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Stamps the on-board viewer link onto a payload's weather
|
|
858
|
+
* provenance record (work doc #31). Payloads whose cache predates
|
|
859
|
+
* the provenance field keep no record — the UI degrades to no
|
|
860
|
+
* glyph, never a dead link.
|
|
861
|
+
*
|
|
862
|
+
* @param {object} payload - Briefing payload, mutated in place
|
|
863
|
+
* @returns {void}
|
|
864
|
+
*/
|
|
865
|
+
function attachWeatherViewer(payload) {
|
|
866
|
+
const base = weatherViewerBase();
|
|
867
|
+
if (base && payload?.metadata?.provenance) {
|
|
868
|
+
payload.metadata.provenance.viewerBase = base;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
833
872
|
async function attachHazardEvents(payload, vessel, waypoints) {
|
|
834
873
|
if (!hazardEventsEnabled) {
|
|
835
874
|
return;
|
|
@@ -854,7 +893,22 @@ module.exports = (app) => {
|
|
|
854
893
|
...hazardFilterConfig,
|
|
855
894
|
now: new Date(),
|
|
856
895
|
});
|
|
857
|
-
|
|
896
|
+
// Provenance (work doc #31): the GDACS event page rides each
|
|
897
|
+
// surviving event — one tap from timeline row to origin
|
|
898
|
+
payload.hazardEvents = events.map((event) =>
|
|
899
|
+
event.link
|
|
900
|
+
? {
|
|
901
|
+
...event,
|
|
902
|
+
provenance: {
|
|
903
|
+
kind: "warning",
|
|
904
|
+
label: "GDACS",
|
|
905
|
+
url: event.link,
|
|
906
|
+
viewerUrl: null,
|
|
907
|
+
at: event.timestamp ?? null,
|
|
908
|
+
},
|
|
909
|
+
}
|
|
910
|
+
: event,
|
|
911
|
+
);
|
|
858
912
|
if (notesStore) {
|
|
859
913
|
const result = await publishHazardNotes({ store: notesStore, events });
|
|
860
914
|
if (result.published.length > 0 || result.pruned.length > 0) {
|
|
@@ -954,12 +1008,33 @@ module.exports = (app) => {
|
|
|
954
1008
|
}
|
|
955
1009
|
const events =
|
|
956
1010
|
result?.events ?? (await capSource.loadCapAlerts(app.getDataDirPath()));
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1011
|
+
// Provenance (work doc #31): the sender's info page when
|
|
1012
|
+
// published, else the feed the document arrived from. Stamped on
|
|
1013
|
+
// the FILTERED survivors — stamping the raw cache instead would
|
|
1014
|
+
// bypass the severity, expiry and geography gates (a Puerto Rico
|
|
1015
|
+
// information statement reached a Tonga vessel that way once).
|
|
1016
|
+
payload.capAlerts = capSource
|
|
1017
|
+
.filterCapAlerts({
|
|
1018
|
+
events,
|
|
1019
|
+
track: waypoints.map((w) => [w.lon, w.lat]),
|
|
1020
|
+
minSeverity: capMinSeverity,
|
|
1021
|
+
now: new Date(),
|
|
1022
|
+
})
|
|
1023
|
+
.map((alert) => {
|
|
1024
|
+
const url = alert.web ?? alert.sourceUrl ?? null;
|
|
1025
|
+
return url
|
|
1026
|
+
? {
|
|
1027
|
+
...alert,
|
|
1028
|
+
provenance: {
|
|
1029
|
+
kind: "warning",
|
|
1030
|
+
label: alert.web ? "Official alert page" : "CAP feed",
|
|
1031
|
+
url,
|
|
1032
|
+
viewerUrl: null,
|
|
1033
|
+
at: alert.sent ?? null,
|
|
1034
|
+
},
|
|
1035
|
+
}
|
|
1036
|
+
: alert;
|
|
1037
|
+
});
|
|
963
1038
|
if (notesStore) {
|
|
964
1039
|
const result = await publishCapAlertNotes({
|
|
965
1040
|
store: notesStore,
|
|
@@ -1045,6 +1120,7 @@ module.exports = (app) => {
|
|
|
1045
1120
|
}),
|
|
1046
1121
|
);
|
|
1047
1122
|
payload.metadata.mode = "here";
|
|
1123
|
+
attachWeatherViewer(payload);
|
|
1048
1124
|
const bulletin = await bulletinForTrack(waypoints);
|
|
1049
1125
|
if (bulletin) {
|
|
1050
1126
|
payload.metareaBulletin = bulletin;
|
|
@@ -1208,6 +1284,7 @@ module.exports = (app) => {
|
|
|
1208
1284
|
const payload = await withSourceStatus(WEATHER_SOURCE, () =>
|
|
1209
1285
|
trackWeatherFetcher({ waypoints, forecastDays }),
|
|
1210
1286
|
);
|
|
1287
|
+
attachWeatherViewer(payload);
|
|
1211
1288
|
const bulletin = await bulletinForTrack(waypoints);
|
|
1212
1289
|
if (bulletin) {
|
|
1213
1290
|
payload.metareaBulletin = bulletin;
|
|
@@ -1782,18 +1859,6 @@ module.exports = (app) => {
|
|
|
1782
1859
|
}
|
|
1783
1860
|
}
|
|
1784
1861
|
|
|
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
1862
|
/** Startup grace (ms) before the delta-fed path sources are
|
|
1798
1863
|
* recorded in the status registry (work doc #23, bergie session
|
|
1799
1864
|
* note): the server replays current values to new subscriptions a
|
|
@@ -1932,19 +1997,12 @@ module.exports = (app) => {
|
|
|
1932
1997
|
now,
|
|
1933
1998
|
});
|
|
1934
1999
|
}
|
|
1935
|
-
let logbookReadable = false;
|
|
1936
|
-
try {
|
|
1937
|
-
await readdir(logbookStoreDir());
|
|
1938
|
-
logbookReadable = true;
|
|
1939
|
-
} catch (_error) {
|
|
1940
|
-
logbookReadable = false;
|
|
1941
|
-
}
|
|
1942
2000
|
sourceStatus.record({
|
|
1943
2001
|
id: "sk-logbook",
|
|
1944
2002
|
label: "Logbook (signalk-logbook)",
|
|
1945
2003
|
kind: "signalk",
|
|
1946
|
-
url:
|
|
1947
|
-
...(
|
|
2004
|
+
url: "resources/logentries",
|
|
2005
|
+
...((await logbookAvailable(app)) ? {} : { absent: true }),
|
|
1948
2006
|
now,
|
|
1949
2007
|
});
|
|
1950
2008
|
}
|
|
@@ -2440,7 +2498,7 @@ module.exports = (app) => {
|
|
|
2440
2498
|
router.get("/api/logbook-events", async (req, res) => {
|
|
2441
2499
|
try {
|
|
2442
2500
|
const events = await readLogbookSailEvents({
|
|
2443
|
-
|
|
2501
|
+
app,
|
|
2444
2502
|
from:
|
|
2445
2503
|
typeof req.query.from === "string" ? req.query.from : undefined,
|
|
2446
2504
|
to: typeof req.query.to === "string" ? req.query.to : undefined,
|
|
@@ -2700,7 +2758,7 @@ module.exports = (app) => {
|
|
|
2700
2758
|
const to =
|
|
2701
2759
|
typeof req.query.to === "string" ? req.query.to : undefined;
|
|
2702
2760
|
const events = await readLogbookSailEvents({
|
|
2703
|
-
|
|
2761
|
+
app,
|
|
2704
2762
|
from,
|
|
2705
2763
|
to,
|
|
2706
2764
|
knownSailKeys: await knownSailKeys(),
|
|
@@ -2709,9 +2767,7 @@ module.exports = (app) => {
|
|
|
2709
2767
|
req.query.source === "history" &&
|
|
2710
2768
|
typeof req.query.baseUrl === "string"
|
|
2711
2769
|
? createHistoryWindStats({ baseUrl: req.query.baseUrl })
|
|
2712
|
-
: createLogbookWindStats(
|
|
2713
|
-
await readLogbookEntries(logbookStoreDir()),
|
|
2714
|
-
);
|
|
2770
|
+
: createLogbookWindStats(await readLogbookEntries(app));
|
|
2715
2771
|
const summary = await backfillSailEvents({
|
|
2716
2772
|
db,
|
|
2717
2773
|
events,
|