@meri-imperiumi/signalk-passage-briefing 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Bulletin source: internet ingestion of high-seas text bulletins
3
+ * (work doc #4 §1) with the same offline constraints as the weather
4
+ * fetches — internet only while the link is up, everything cached on
5
+ * disk so the briefing survives the other 23 offline hours.
6
+ *
7
+ * The configured URLs are pulled verbatim (plain text or RSS/XML —
8
+ * anything text-shaped runs through the same filtering pipeline).
9
+ * Raw texts persist in `weather/bulletins.json` keyed by URL; the
10
+ * newest entry per URL wins, and the cache keeps the freshest
11
+ * `KEEP_BULLETINS` entries overall.
12
+ *
13
+ * The radio paths (VARA HF spool files, Winlink chunks) plug into
14
+ * the same pipeline later via the spool watcher.
15
+ *
16
+ * @file bulletin-source.js
17
+ */
18
+
19
+ const { mkdir, readFile, writeFile } = require("node:fs/promises");
20
+ const { join } = require("node:path");
21
+ const { resolveBulletinSource } = require("./bulletin-engine.js");
22
+
23
+ /** Maximum cached bulletin entries across all URLs. */
24
+ const KEEP_BULLETINS = 20;
25
+
26
+ /**
27
+ * @typedef {{url: string, fetchedAt: string, text: string, source?: "api"|"spool"}} BulletinEntry
28
+ */
29
+
30
+ /**
31
+ * The cache file path under the plugin data directory.
32
+ *
33
+ * @param {string} dataDir
34
+ * @returns {string}
35
+ */
36
+ function cachePath(dataDir) {
37
+ return join(dataDir, "weather", "bulletins.json");
38
+ }
39
+
40
+ /**
41
+ * Loads the bulletin cache. Missing or corrupt file → empty cache
42
+ * (the next online refresh repopulates it).
43
+ *
44
+ * @param {string} dataDir
45
+ * @returns {Promise<BulletinEntry[]>} Newest first
46
+ */
47
+ async function loadBulletinCache(dataDir) {
48
+ try {
49
+ const raw = JSON.parse(await readFile(cachePath(dataDir), "utf8"));
50
+ const entries = Array.isArray(raw.entries) ? raw.entries : [];
51
+ return entries
52
+ .filter(
53
+ (e) =>
54
+ e &&
55
+ typeof e.url === "string" &&
56
+ typeof e.fetchedAt === "string" &&
57
+ typeof e.text === "string",
58
+ )
59
+ .sort((a, b) => b.fetchedAt.localeCompare(a.fetchedAt));
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Persists the bulletin cache (newest first, pruned).
67
+ *
68
+ * @param {string} dataDir
69
+ * @param {BulletinEntry[]} entries
70
+ * @returns {Promise<void>}
71
+ */
72
+ async function saveBulletinCache(dataDir, entries) {
73
+ const pruned = [...entries]
74
+ .sort((a, b) => b.fetchedAt.localeCompare(a.fetchedAt))
75
+ .slice(0, KEEP_BULLETINS);
76
+ await mkdir(join(dataDir, "weather"), { recursive: true });
77
+ await writeFile(
78
+ cachePath(dataDir),
79
+ `${JSON.stringify({ entries: pruned }, null, 2)}\n`,
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Fetches every configured bulletin URL and merges the results into
85
+ * the cache. Per-URL failures skip that URL without failing the
86
+ * batch — a 404 on one source must not lose the others. Callers
87
+ * gate on the online state (SPEC §2.2), exactly like the weather
88
+ * fetches. Pre-fetched zone bulletins (from zone-source's ladder)
89
+ * merge without a second fetch.
90
+ *
91
+ * @param {object} params
92
+ * @param {string} params.dataDir - Plugin data directory
93
+ * @param {string[]} params.urls - Bulletin source URLs
94
+ * @param {Array<{url: string, text: string, source: string, zone: number}>}
95
+ * [params.zoneBulletins] - Already-fetched zone bulletins
96
+ * @param {typeof fetch} [params.fetchImpl]
97
+ * @param {number} [params.timeoutMs]
98
+ * @returns {Promise<{fetched: string[], failed: string[], entries: BulletinEntry[]}>}
99
+ * Newly fetched URLs (failures listed separately) and the merged
100
+ * cache
101
+ */
102
+ async function refreshBulletins({
103
+ dataDir,
104
+ urls,
105
+ zoneBulletins = [],
106
+ fetchImpl = fetch,
107
+ timeoutMs,
108
+ }) {
109
+ const cached = await loadBulletinCache(dataDir);
110
+ const fetched = [];
111
+ const failed = [];
112
+ for (const entry of zoneBulletins) {
113
+ cached.unshift({
114
+ url: entry.url,
115
+ fetchedAt: new Date().toISOString(),
116
+ text: entry.text,
117
+ source: entry.source,
118
+ zone: entry.zone,
119
+ ...(entry.format ? { format: entry.format } : {}),
120
+ });
121
+ fetched.push(entry.url);
122
+ }
123
+ for (const url of urls) {
124
+ try {
125
+ const { text, source } = await resolveBulletinSource(url, {
126
+ fetchImpl,
127
+ ...(timeoutMs != null ? { timeoutMs } : {}),
128
+ });
129
+ cached.unshift({
130
+ url,
131
+ fetchedAt: new Date().toISOString(),
132
+ text,
133
+ source,
134
+ });
135
+ fetched.push(url);
136
+ } catch {
137
+ failed.push(url);
138
+ }
139
+ }
140
+ await saveBulletinCache(dataDir, cached);
141
+ return { fetched, failed, entries: cached };
142
+ }
143
+
144
+ module.exports = {
145
+ KEEP_BULLETINS,
146
+ cachePath,
147
+ loadBulletinCache,
148
+ saveBulletinCache,
149
+ refreshBulletins,
150
+ };
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Celestial & space weather source (work doc #3, Phase 1):
3
+ * ultra-lightweight fetch-driven events with coarse gating —
4
+ *
5
+ * - Aurora: NOAA SWPC 3-day planetary K-index forecast; an alert is
6
+ * emitted when the predicted Kp reaches storm level (≥ 5) and the
7
+ * vessel's magnetic latitude is far enough south/north for that Kp
8
+ * to be observable.
9
+ * - Naked-eye comets: JPL Small-Body Database query for comets with
10
+ * predicted apparent magnitude brighter than 6.0.
11
+ *
12
+ * Everything else (twilight, moon, planets, meteors, satellite
13
+ * passes) is Phase 2 and deliberately not attempted here.
14
+ *
15
+ * Both endpoints degrade independently: a failure nulls its half, so
16
+ * a blocked JPL host never costs the aurora alert.
17
+ *
18
+ * @file celestial-source.js
19
+ */
20
+
21
+ /**
22
+ * Kp at which a geomagnetic storm (and mid-latitude aurora chance)
23
+ * begins (NOAA G-scale G1).
24
+ */
25
+ const STORM_KP = 5;
26
+
27
+ /**
28
+ * Naked-eye visibility limit for comets.
29
+ */
30
+ const NAKED_EYE_MAGNITUDE = 6.0;
31
+
32
+ /**
33
+ * Position of the geomagnetic north pole (2025 epoch approximation).
34
+ * Good enough for coarse aurora gating; not for navigation.
35
+ */
36
+ const GEOMAGNETIC_POLE = { lat: 80.0, lon: -72.0 };
37
+
38
+ /**
39
+ * Coarse equatorward magnetic-latitude reach of the auroral oval for
40
+ * a given Kp: about 65° at Kp 5, stretching to ~45° at Kp 9.
41
+ *
42
+ * @param {number} kp
43
+ * @returns {number} Absolute magnetic-latitude threshold in degrees
44
+ */
45
+ function auroraMagLatThreshold(kp) {
46
+ return Math.max(40, 90 - 5 * kp);
47
+ }
48
+
49
+ /**
50
+ * Magnetic (dipole) latitude for a geographic position, using a
51
+ * fixed geomagnetic pole approximation.
52
+ *
53
+ * @param {number} lat - Geographic latitude, degrees
54
+ * @param {number} lon - Geographic longitude, degrees east
55
+ * @returns {number} Magnetic latitude, degrees
56
+ */
57
+ function magneticLatitudeDeg(lat, lon) {
58
+ const toRad = Math.PI / 180;
59
+ const φ = lat * toRad;
60
+ const λ = lon * toRad;
61
+ const φ0 = GEOMAGNETIC_POLE.lat * toRad;
62
+ const λ0 = GEOMAGNETIC_POLE.lon * toRad;
63
+ return (
64
+ Math.asin(
65
+ Math.sin(φ) * Math.sin(φ0) +
66
+ Math.cos(φ) * Math.cos(φ0) * Math.cos(λ - λ0),
67
+ ) / toRad
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Parses an SWPC planetary K-index forecast product and returns the
73
+ * entries inside the forward window, sorted by time.
74
+ *
75
+ * @param {Array<{time_tag: string, kp: number, observed?: string}>|
76
+ * null} json - Parsed SWPC product (3-hour steps)
77
+ * @param {object} [options]
78
+ * @param {Date} [options.from] - Window start (default now)
79
+ * @param {number} [options.hours=24] - Window length
80
+ * @returns {Array<{timestamp: string, kp: number, predicted: boolean}>}
81
+ */
82
+ function parseKpForecast(json, { from = new Date(), hours = 24 } = {}) {
83
+ if (!Array.isArray(json)) {
84
+ return [];
85
+ }
86
+ const start = from.getTime();
87
+ const end = start + hours * 3600000;
88
+ const entries = [];
89
+ for (const row of json) {
90
+ const t = new Date(row?.time_tag).getTime();
91
+ const kp = row?.kp;
92
+ if (!Number.isFinite(t) || typeof kp !== "number" || !Number.isFinite(kp)) {
93
+ continue;
94
+ }
95
+ if (t < start || t >= end) {
96
+ continue;
97
+ }
98
+ entries.push({
99
+ timestamp: new Date(t).toISOString(),
100
+ kp,
101
+ predicted: row.observed !== "observed",
102
+ });
103
+ }
104
+ return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
105
+ }
106
+
107
+ /**
108
+ * Apparent magnitude of a comet from the standard total-magnitude
109
+ * formula m = M1 + 5·log10(Δ) + K1·log10(r).
110
+ *
111
+ * @param {object} p
112
+ * @param {number} p.M1 - Total magnitude parameter
113
+ * @param {number} p.K1 - Brightness slope parameter
114
+ * @param {number} p.r - Heliocentric distance, au
115
+ * @param {number} p.delta - Geocentric distance, au
116
+ * @returns {number|null}
117
+ */
118
+ function cometApparentMagnitude({ M1, K1, r, delta }) {
119
+ if (
120
+ [M1, K1, r, delta].some(
121
+ (v) => typeof v !== "number" || !Number.isFinite(v),
122
+ ) ||
123
+ r <= 0 ||
124
+ delta <= 0
125
+ ) {
126
+ return null;
127
+ }
128
+ return M1 + 5 * Math.log10(delta) + K1 * Math.log10(r);
129
+ }
130
+
131
+ /**
132
+ * Parses an SBDB query response down to the naked-eye comets.
133
+ *
134
+ * @param {{fields?: string[], rows?: Array<Array<string|number>>}|
135
+ * null} json - Parsed SBDB query API response
136
+ * @param {object} [options]
137
+ * @param {number} [options.maxMagnitude] - Brightness limit
138
+ * @returns {Array<{name: string, magnitude: number, M1: number,
139
+ * K1: number, r: number, delta: number}>}
140
+ */
141
+ function parseComets(json, { maxMagnitude = NAKED_EYE_MAGNITUDE } = {}) {
142
+ const fields = json?.fields;
143
+ const rows = json?.rows;
144
+ if (!Array.isArray(fields) || !Array.isArray(rows)) {
145
+ return [];
146
+ }
147
+ const index = (name) =>
148
+ fields.findIndex((f) => String(f).toLowerCase() === name);
149
+ const iName = index("full_name");
150
+ const iM1 = index("m1");
151
+ const iK1 = index("k1");
152
+ const iR = index("r");
153
+ const iDelta = index("dist");
154
+ const iQ = index("q");
155
+ const iTpCal = index("tp_cal");
156
+ if (iName < 0 || iM1 < 0 || iK1 < 0) {
157
+ return [];
158
+ }
159
+ const comets = [];
160
+ for (const row of rows) {
161
+ const M1 = Number(row[iM1]);
162
+ const K1 = Number(row[iK1]);
163
+ const name = row[iName];
164
+ if (
165
+ ![M1, K1].every((v) => Number.isFinite(v)) ||
166
+ typeof name !== "string"
167
+ ) {
168
+ continue;
169
+ }
170
+ if (iR >= 0 && iDelta >= 0) {
171
+ // Current distances available: apparent magnitude now
172
+ const r = Number(row[iR]);
173
+ const delta = Number(row[iDelta]);
174
+ const magnitude = cometApparentMagnitude({ M1, K1, r, delta });
175
+ if (magnitude == null) {
176
+ continue;
177
+ }
178
+ if (magnitude <= maxMagnitude) {
179
+ comets.push({
180
+ name: name.trim(),
181
+ magnitude: Math.round(magnitude * 10) / 10,
182
+ estimate: "now",
183
+ M1,
184
+ K1,
185
+ r,
186
+ delta,
187
+ });
188
+ }
189
+ continue;
190
+ }
191
+ // No current distances from this endpoint: screen on the
192
+ // perihelion brightness instead (r = delta = q is the comet at
193
+ // its brightest). The result is a peak-brightness estimate, not
194
+ // a now-cast — flagged so the display can say so.
195
+ if (iQ < 0) {
196
+ continue;
197
+ }
198
+ const q = Number(row[iQ]);
199
+ if (!Number.isFinite(q) || q <= 0) {
200
+ continue;
201
+ }
202
+ const magnitude = cometApparentMagnitude({ M1, K1, r: q, delta: q });
203
+ if (magnitude == null) {
204
+ continue;
205
+ }
206
+ if (magnitude <= maxMagnitude) {
207
+ comets.push({
208
+ name: name.trim(),
209
+ magnitude: Math.round(magnitude * 10) / 10,
210
+ estimate: "perihelion",
211
+ perihelion: iTpCal >= 0 ? row[iTpCal] : null,
212
+ M1,
213
+ K1,
214
+ q,
215
+ });
216
+ }
217
+ }
218
+ return comets.sort((a, b) => a.magnitude - b.magnitude);
219
+ }
220
+
221
+ const SWPC_KP_URL =
222
+ "https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json";
223
+
224
+ /** Field list the SBDB comet query is attempted with. The API rejects
225
+ * unknown/unsupported fields one at a time (400 "invalid field
226
+ * specified: 'x'"), so fetchComets negotiates the list down to what
227
+ * the live endpoint accepts. `r`/`dist` (current heliocentric and
228
+ * geocentric distance) are not documented query fields for comets —
229
+ * `q` (perihelion distance) is, and powers the fallback estimate. */
230
+ const SBDB_COMET_FIELDS = ["full_name", "M1", "K1", "r", "dist", "q", "tp_cal"];
231
+
232
+ function sbdbCometsUrl(fields) {
233
+ return `https://ssd-api.jpl.nasa.gov/sbdb_query.api?fields=${fields.join(",")}&sb-kind=c`;
234
+ }
235
+
236
+ /**
237
+ * Builds the space-event list from parsed inputs (all gating that
238
+ * needs no network happens here).
239
+ *
240
+ * @param {object} params
241
+ * @param {Array<{timestamp: string, kp: number}>} [params.kpEntries]
242
+ * @param {Array<{name: string, magnitude: number}>} [params.comets]
243
+ * @param {number} params.lat - Vessel latitude
244
+ * @param {number} params.lon - Vessel longitude
245
+ * @param {Date} [params.now]
246
+ * @param {(date: Date, lat: number, lon: number) => boolean} [params.isNightFn]
247
+ * Night test (default: the shared Sereno solar geometry)
248
+ * @returns {Array<{kind: string, timestamp: string, tactical: boolean, description: string, kp?: number, name?: string, magnitude?: number}>}
249
+ */
250
+ async function buildSpaceEvents({
251
+ kpEntries = [],
252
+ comets = [],
253
+ lat,
254
+ lon,
255
+ now = new Date(),
256
+ isNightFn,
257
+ }) {
258
+ const isNight =
259
+ isNightFn ?? (await import("../public/sereno-physics.mjs")).isNight;
260
+ const events = [];
261
+
262
+ // Aurora: strongest predicted Kp in the window, gated by magnetic
263
+ // latitude and a local-night check at the vessel
264
+ const peak = kpEntries.reduce(
265
+ (best, e) => (best == null || e.kp > best.kp ? e : best),
266
+ null,
267
+ );
268
+ if (peak && peak.kp >= STORM_KP) {
269
+ const magLat = magneticLatitudeDeg(lat, lon);
270
+ if (Math.abs(magLat) >= auroraMagLatThreshold(peak.kp)) {
271
+ const look = lat >= 0 ? "north" : "south";
272
+ if (isNight(new Date(peak.timestamp), lat, lon)) {
273
+ events.push({
274
+ kind: "aurora",
275
+ timestamp: peak.timestamp,
276
+ tactical: true,
277
+ kp: peak.kp,
278
+ description: `Aurora possible: Kp ${peak.kp.toFixed(0)} predicted tonight. Look ${look}.`,
279
+ });
280
+ }
281
+ }
282
+ }
283
+
284
+ // Naked-eye comets: strategic items, no precision claimed
285
+ for (const comet of comets) {
286
+ events.push({
287
+ kind: "comet",
288
+ timestamp: now.toISOString(),
289
+ tactical: false,
290
+ name: comet.name,
291
+ magnitude: comet.magnitude,
292
+ description:
293
+ comet.estimate === "perihelion"
294
+ ? `Comet ${comet.name} peaks near mag ${comet.magnitude.toFixed(1)} at perihelion${comet.perihelion ? ` (${comet.perihelion})` : ""} — ephemeris check advised`
295
+ : `Naked-eye comet ${comet.name} (mag ${comet.magnitude.toFixed(1)}) in range`,
296
+ });
297
+ }
298
+ return events;
299
+ }
300
+
301
+ /**
302
+ * Fetches both sources and builds the event list. Each source fails
303
+ * independently; a total failure yields an empty list.
304
+ *
305
+ * @param {object} params
306
+ * @param {number} params.lat
307
+ * @param {number} params.lon
308
+ * @param {Date} [params.now]
309
+ * @param {typeof fetch} [params.fetchImpl]
310
+ * @param {number} [params.timeoutMs]
311
+ * @param {(date: Date, lat: number, lon: number) => boolean} [params.isNightFn]
312
+ * Night test override (tests)
313
+ * @returns {Promise<Array<object>>} Space events (possibly empty)
314
+ */
315
+ /**
316
+ * Fetches the SBDB comet query, negotiating the field list against
317
+ * the live endpoint: each 400 names one invalid field, which is
318
+ * dropped and the query retried until it answers or the fields run
319
+ * out.
320
+ *
321
+ * @param {object} params
322
+ * @param {typeof fetch} params.fetchImpl
323
+ * @param {number} params.timeoutMs
324
+ * @returns {Promise<object|null>} Parsed `{fields, rows}` response,
325
+ * null when every attempt failed
326
+ */
327
+ async function fetchComets({ fetchImpl, timeoutMs }) {
328
+ let fields = [...SBDB_COMET_FIELDS];
329
+ while (fields.length >= 2) {
330
+ const controller = new AbortController();
331
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
332
+ let response;
333
+ try {
334
+ response = await fetchImpl(sbdbCometsUrl(fields), {
335
+ signal: controller.signal,
336
+ });
337
+ } catch (_error) {
338
+ return null;
339
+ } finally {
340
+ clearTimeout(timer);
341
+ }
342
+ if (!response.ok) {
343
+ const detail = await response.text().catch(() => "");
344
+ const invalid = detail.match(/invalid field specified: '(\w+)'/);
345
+ if (response.status === 400 && invalid) {
346
+ fields = fields.filter(
347
+ (field) => field.toLowerCase() !== invalid[1].toLowerCase(),
348
+ );
349
+ continue;
350
+ }
351
+ return null;
352
+ }
353
+ return await response.json();
354
+ }
355
+ return null;
356
+ }
357
+
358
+ async function fetchSpaceEvents({
359
+ lat,
360
+ lon,
361
+ now = new Date(),
362
+ fetchImpl = fetch,
363
+ timeoutMs = 15000,
364
+ isNightFn,
365
+ }) {
366
+ const grab = async (url) => {
367
+ const controller = new AbortController();
368
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
369
+ try {
370
+ const response = await fetchImpl(url, { signal: controller.signal });
371
+ if (!response.ok) {
372
+ return null;
373
+ }
374
+ return await response.json();
375
+ } catch (_error) {
376
+ return null;
377
+ } finally {
378
+ clearTimeout(timer);
379
+ }
380
+ };
381
+
382
+ const [kpJson, cometJson] = await Promise.all([
383
+ grab(SWPC_KP_URL),
384
+ fetchComets({ fetchImpl, timeoutMs }),
385
+ ]);
386
+ const kpEntries = kpJson ? parseKpForecast(kpJson, { from: now }) : [];
387
+ const comets = cometJson ? parseComets(cometJson) : [];
388
+ if (kpEntries.length === 0 && comets.length === 0) {
389
+ return [];
390
+ }
391
+ try {
392
+ return await buildSpaceEvents({
393
+ kpEntries,
394
+ comets,
395
+ lat,
396
+ lon,
397
+ now,
398
+ isNightFn,
399
+ });
400
+ } catch (_error) {
401
+ return [];
402
+ }
403
+ }
404
+
405
+ module.exports = {
406
+ STORM_KP,
407
+ NAKED_EYE_MAGNITUDE,
408
+ auroraMagLatThreshold,
409
+ magneticLatitudeDeg,
410
+ parseKpForecast,
411
+ cometApparentMagnitude,
412
+ parseComets,
413
+ fetchComets,
414
+ sbdbCometsUrl,
415
+ buildSpaceEvents,
416
+ fetchSpaceEvents,
417
+ };