@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,248 @@
1
+ /**
2
+ * `<strategic-outlook>` — Screen 2: whole-passage summary (SPEC
3
+ * §6.3). ETA percentile table, macro sea-state warnings, convective
4
+ * warnings and the raw METAREA bulletin console.
5
+ *
6
+ * @file components/strategic-outlook.js
7
+ */
8
+
9
+ import { etaTable, sailWorkTimeline, splitSevere } from "./models.mjs";
10
+ import { SK_BASE_CSS } from "./sk-base-css.js";
11
+
12
+ /**
13
+ * The custom element (browser only).
14
+ */
15
+ class StrategicOutlook extends HTMLElement {
16
+ connectedCallback() {
17
+ if (!this.shadowRoot) {
18
+ this.attachShadow({ mode: "open" });
19
+ }
20
+ this.shadowRoot.innerHTML = `
21
+ <style>
22
+ ${SK_BASE_CSS}
23
+ :host { display: block; }
24
+ .sk-card { margin-bottom: 12px; }
25
+ pre.console { margin: 0; }
26
+ .warn {
27
+ color: var(--color-orange);
28
+ font-family: var(--font-data, ui-monospace, monospace);
29
+ font-size: 0.85rem;
30
+ padding: 4px 0;
31
+ }
32
+ .warn.severe { color: var(--color-red); }
33
+ .none { color: var(--text-muted); font-size: 0.85rem; }
34
+ strong.sev {
35
+ color: var(--color-orange);
36
+ text-transform: uppercase;
37
+ }
38
+ </style>
39
+ <section class="sk-card theme-teal">
40
+ <h2>ETA &amp; Motor Plan</h2>
41
+ <table class="data">
42
+ <thead>
43
+ <tr><th>Percentile</th><th>Arrival (UTC)</th></tr>
44
+ </thead>
45
+ <tbody id="eta-body"></tbody>
46
+ </table>
47
+ <div class="grid-auto" style="margin-top: 8px">
48
+ <div><div class="label">Motor hours</div><div class="value-small" id="motor">—</div></div>
49
+ <div><div class="label">Fuel</div><div class="value-small" id="fuel">—</div></div>
50
+ </div>
51
+ </section>
52
+ <section class="sk-card theme-orange">
53
+ <h2>Macro Sea State</h2>
54
+ <div id="sea"><span class="none">No anomalies</span></div>
55
+ </section>
56
+ <section class="sk-card theme-red">
57
+ <h2>Convective Risk</h2>
58
+ <div id="conv"><span class="none">No warnings</span></div>
59
+ </section>
60
+ <section class="sk-card theme-orange">
61
+ <h2>Sail Work</h2>
62
+ <div id="sailwork"><span class="none">No sail changes planned</span></div>
63
+ </section>
64
+ <section class="sk-card" id="sky-card" hidden>
65
+ <h2>Sky Notes</h2>
66
+ <div id="sky"></div>
67
+ </section>
68
+ <section class="sk-card theme-teal">
69
+ <h2>METAREA Bulletin</h2>
70
+ <pre class="console" id="bulletin">No bulletin cached</pre>
71
+ </section>
72
+ <section class="sk-card theme-red" id="blocks-card" hidden>
73
+ <h2>Warnings On Your Waters</h2>
74
+ <div class="console" id="blocks"></div>
75
+ </section>
76
+ <synoptic-chart hidden></synoptic-chart>
77
+ `;
78
+ this._etaBody = this.shadowRoot.getElementById("eta-body");
79
+ this._motorEl = this.shadowRoot.getElementById("motor");
80
+ this._fuelEl = this.shadowRoot.getElementById("fuel");
81
+ this._seaEl = this.shadowRoot.getElementById("sea");
82
+ this._convEl = this.shadowRoot.getElementById("conv");
83
+ this._sailWorkEl = this.shadowRoot.getElementById("sailwork");
84
+ this._skyCard = this.shadowRoot.getElementById("sky-card");
85
+ this._skyEl = this.shadowRoot.getElementById("sky");
86
+ this._bulletinEl = this.shadowRoot.getElementById("bulletin");
87
+ if (this._exceptions) {
88
+ this.setExceptions(this._exceptions);
89
+ }
90
+ if (this._bulletin !== undefined) {
91
+ this.setBulletin(this._bulletin);
92
+ }
93
+ if (this._spaceEvents !== undefined) {
94
+ this.setSpaceEvents(this._spaceEvents);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Sky notes (work doc #3): comets and any other non-urgent space
100
+ * events for the passage summary. Hidden when there are none.
101
+ *
102
+ * @param {Array<{description: string, tactical: boolean}>|null} events
103
+ */
104
+ setSpaceEvents(events) {
105
+ this._spaceEvents = events;
106
+ if (!this._skyEl) {
107
+ return; // Not yet connected
108
+ }
109
+ const list = (events ?? []).filter((e) => !e.tactical);
110
+ this._skyCard.hidden = list.length === 0;
111
+ this._skyEl.innerHTML = "";
112
+ for (const e of list) {
113
+ const el = document.createElement("div");
114
+ el.textContent = `✦ ${e.description}`;
115
+ this._skyEl.appendChild(el);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Renders from the worker's exception view.
121
+ *
122
+ * @param {object|null} exceptions
123
+ */
124
+ setExceptions(exceptions) {
125
+ this._exceptions = exceptions;
126
+ if (!this._etaBody) {
127
+ return; // Not yet connected
128
+ }
129
+ const table = etaTable(exceptions);
130
+ this._etaBody.innerHTML = "";
131
+ for (const row of table.rows) {
132
+ const tr = document.createElement("tr");
133
+ const tdLabel = document.createElement("td");
134
+ tdLabel.className = "muted";
135
+ tdLabel.textContent = row.label;
136
+ const tdStamp = document.createElement("td");
137
+ tdStamp.textContent = row.stamp || "—";
138
+ tr.append(tdLabel, tdStamp);
139
+ this._etaBody.appendChild(tr);
140
+ }
141
+ this._motorEl.textContent = table.motorHours || "—";
142
+ this._fuelEl.textContent = table.fuel || "—";
143
+
144
+ // Macro sea-state anomalies
145
+ this._seaEl.innerHTML = "";
146
+ const sea = exceptions?.passageSummary?.macroSeaAnomalies ?? [];
147
+ for (const a of sea) {
148
+ const el = document.createElement("div");
149
+ el.className = "warn";
150
+ el.textContent = `+${a.hoursFromNow}h steep sea (ratio ${a.steepnessRatio})`;
151
+ this._seaEl.appendChild(el);
152
+ }
153
+ if (sea.length === 0) {
154
+ const el = document.createElement("span");
155
+ el.className = "none";
156
+ el.textContent = "No anomalies";
157
+ this._seaEl.appendChild(el);
158
+ }
159
+
160
+ // Sail work: recommendations plus the planned tacks/gybes (doc #5)
161
+ this._sailWorkEl.innerHTML = "";
162
+ const sailWork = sailWorkTimeline(exceptions);
163
+ for (const item of sailWork) {
164
+ const el = document.createElement("div");
165
+ el.className =
166
+ item.label.startsWith("Tack") || item.label.startsWith("Gybe")
167
+ ? "warn"
168
+ : "";
169
+ el.textContent = `+${item.hoursFromNow}h ${item.stamp} · ${item.label}${item.detail ? ` · ${item.detail}` : ""}`;
170
+ this._sailWorkEl.appendChild(el);
171
+ }
172
+ if (sailWork.length === 0) {
173
+ const el = document.createElement("span");
174
+ el.className = "none";
175
+ el.textContent = "No sail changes planned";
176
+ this._sailWorkEl.appendChild(el);
177
+ }
178
+
179
+ // Convective warnings
180
+ this._convEl.innerHTML = "";
181
+ const conv = exceptions?.passageSummary?.convectiveWarnings ?? [];
182
+ for (const a of conv) {
183
+ const el = document.createElement("div");
184
+ el.className = "warn severe";
185
+ const parts = [];
186
+ if (a.cape != null) {
187
+ parts.push(`CAPE ${a.cape}`);
188
+ }
189
+ if (a.kIndex != null) {
190
+ parts.push(`K ${a.kIndex}`);
191
+ }
192
+ el.textContent = `+${a.hoursFromNow}h convection (${parts.join(", ")})`;
193
+ this._convEl.appendChild(el);
194
+ }
195
+ if (conv.length === 0) {
196
+ const el = document.createElement("span");
197
+ el.className = "none";
198
+ el.textContent = "No warnings";
199
+ this._convEl.appendChild(el);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Raw METAREA bulletin (spool-sourced when available).
205
+ *
206
+ * @param {string|{bulletinText: string, blocks: Array<{text: string}>}|null}
207
+ * bulletin - The payload's metareaBulletin, or raw text
208
+ */
209
+ setBulletin(bulletin) {
210
+ this._bulletin = bulletin;
211
+ if (!this._bulletinEl) {
212
+ return; // Not yet connected
213
+ }
214
+ const normalized =
215
+ typeof bulletin === "string" ? { bulletinText: bulletin } : bulletin;
216
+ this._bulletinEl.textContent =
217
+ normalized?.bulletinText || "No bulletin cached";
218
+
219
+ // Filtered blocks replace the raw wall of text (work doc #4 §5):
220
+ // only geographically relevant paragraphs, severe keywords lit
221
+ const blocksCard = this.shadowRoot.getElementById("blocks-card");
222
+ const blocksEl = this.shadowRoot.getElementById("blocks");
223
+ const blocks = normalized?.blocks ?? [];
224
+ blocksCard.hidden = blocks.length === 0;
225
+ blocksEl.innerHTML = "";
226
+ for (const block of blocks) {
227
+ const pre = document.createElement("div");
228
+ pre.style.marginBottom = "8px";
229
+ for (const token of splitSevere(block.text)) {
230
+ if (token.severe) {
231
+ const strong = document.createElement("strong");
232
+ strong.className = "sev";
233
+ strong.textContent = token.text;
234
+ pre.appendChild(strong);
235
+ } else {
236
+ pre.appendChild(document.createTextNode(token.text));
237
+ }
238
+ }
239
+ blocksEl.appendChild(pre);
240
+ }
241
+ }
242
+ }
243
+
244
+ if (typeof customElements !== "undefined") {
245
+ customElements.define("strategic-outlook", StrategicOutlook);
246
+ }
247
+
248
+ export { StrategicOutlook };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The synoptic surface-analysis figure (work doc #11): loads the
3
+ * cached chart for the vessel's position zone from the plugin API
4
+ * and shows it inverted under the night palette. Stays hidden until
5
+ * the image actually loads, so zones without a chart render nothing
6
+ * — same graceful handling as the bulletin console. Shared by the
7
+ * strategic screen and the conditions-here view.
8
+ *
9
+ * @file components/synoptic-chart.js
10
+ */
11
+
12
+ const PLUGIN_API = "/plugins/signalk-passage-briefing/api";
13
+
14
+ class SynopticChart extends HTMLElement {
15
+ connectedCallback() {
16
+ if (!this.shadowRoot) {
17
+ this.attachShadow({ mode: "open" });
18
+ }
19
+ this.shadowRoot.innerHTML = `
20
+ <style>
21
+ :host { display: block; }
22
+ :host([hidden]) { display: none; }
23
+ figure { margin: 0; }
24
+ img { width: 100%; height: auto; display: block; }
25
+ :host([data-mode="night"]) img {
26
+ filter: invert(0.93) hue-rotate(180deg);
27
+ }
28
+ </style>
29
+ <figure>
30
+ <img id="img" alt="Synoptic surface analysis for the current zone">
31
+ </figure>
32
+ `;
33
+ const img = this.shadowRoot.getElementById("img");
34
+ img.addEventListener("load", () => {
35
+ this.hidden = false;
36
+ });
37
+ img.addEventListener("error", () => {
38
+ this.hidden = true;
39
+ });
40
+ img.src = `${PLUGIN_API}/synoptic`;
41
+ }
42
+ }
43
+
44
+ if (typeof customElements !== "undefined") {
45
+ customElements.define("synoptic-chart", SynopticChart);
46
+ }
47
+
48
+ export { SynopticChart };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `<tactical-dashboard>` — Screen 1: the next 24 hours (SPEC §6.3).
3
+ *
4
+ * Exception-driven: sail action cards render only when there are
5
+ * changes, the energy warning only on deficit, hazard banners only
6
+ * on proximity alerts. Data arrives via `setExceptions()` from the
7
+ * root component; updates touch cached DOM nodes only.
8
+ *
9
+ * @file components/tactical-dashboard.js
10
+ */
11
+
12
+ import { sailActionCards, tacticalNow } from "./models.mjs";
13
+ import { SK_BASE_CSS } from "./sk-base-css.js";
14
+
15
+ /**
16
+ * The custom element (browser only).
17
+ */
18
+ class TacticalDashboard extends HTMLElement {
19
+ connectedCallback() {
20
+ if (!this.shadowRoot) {
21
+ this.attachShadow({ mode: "open" });
22
+ }
23
+ this.shadowRoot.innerHTML = `
24
+ <style>
25
+ ${SK_BASE_CSS}
26
+ :host { display: block; }
27
+ .now { display: flex; align-items: baseline; gap: 12px; }
28
+ .tier {
29
+ font-family: var(--font-data, ui-monospace, monospace);
30
+ font-size: 1rem; font-weight: 700;
31
+ letter-spacing: 0.1em; text-transform: uppercase;
32
+ }
33
+ .cards { display: grid; gap: 8px; }
34
+ .card {
35
+ border: 1px solid var(--theme-color, var(--color-teal));
36
+ padding: 8px 12px; display: flex; justify-content: space-between;
37
+ gap: 8px; align-items: baseline;
38
+ }
39
+ .banner {
40
+ border: 1px solid var(--color-red);
41
+ color: var(--color-red);
42
+ font-family: var(--font-data, ui-monospace, monospace);
43
+ padding: 8px 12px; margin-top: 8px;
44
+ text-transform: uppercase; letter-spacing: 0.1em;
45
+ font-size: 0.85rem; font-weight: 700;
46
+ }
47
+ </style>
48
+ <section class="sk-card theme-teal">
49
+ <h2>Next 24 Hours</h2>
50
+ <div class="now">
51
+ <span class="value" id="aws">—</span>
52
+ <span class="tier" id="tier">no data</span>
53
+ <comfort-info id="cinfo"></comfort-info>
54
+ </div>
55
+ <horizon-sparkline id="spark"></horizon-sparkline>
56
+ <div id="actions"></div>
57
+ <div id="energy"></div>
58
+ <div id="hazards"></div>
59
+ <div id="space"></div>
60
+ </section>
61
+ `;
62
+ this._awsEl = this.shadowRoot.getElementById("aws");
63
+ this._tierEl = this.shadowRoot.getElementById("tier");
64
+ this._cinfo = this.shadowRoot.getElementById("cinfo");
65
+ this._spark = this.shadowRoot.getElementById("spark");
66
+ this._actionsEl = this.shadowRoot.getElementById("actions");
67
+ this._energyEl = this.shadowRoot.getElementById("energy");
68
+ this._hazardsEl = this.shadowRoot.getElementById("hazards");
69
+ this._spaceEl = this.shadowRoot.getElementById("space");
70
+ if (this._exceptions) {
71
+ this.setExceptions(this._exceptions);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Renders from the worker's exception view.
77
+ *
78
+ * @param {object|null} exceptions
79
+ */
80
+ setExceptions(exceptions) {
81
+ this._exceptions = exceptions;
82
+ if (!this._awsEl) {
83
+ return; // Not yet connected
84
+ }
85
+
86
+ // Now readout
87
+ const now = tacticalNow(exceptions);
88
+ this._awsEl.textContent =
89
+ now.awsKnots != null ? `${now.awsKnots.toFixed(1)}` : "—";
90
+ this._tierEl.textContent = now.comfortLevel ?? "no data";
91
+ this._tierEl.style.color = now.color;
92
+ this._cinfo?.setAttribute("tier", now.comfortLevel ?? "");
93
+
94
+ this._spark.setColumns(exceptions?.next24h?.comfortBlocks ?? []);
95
+
96
+ // Sail action cards — only when there are changes; maneuver
97
+ // events read as the sail work they demand (work doc #5)
98
+ const cards = sailActionCards(exceptions);
99
+ this._actionsEl.innerHTML = "";
100
+ if (cards.length > 0) {
101
+ const wrap = document.createElement("div");
102
+ wrap.className = "cards";
103
+ for (const c of cards) {
104
+ const el = document.createElement("div");
105
+ el.className = "card";
106
+ el.style.setProperty("--theme-color", "var(--color-orange)");
107
+ const name = document.createElement("span");
108
+ name.className = "value-small";
109
+ name.textContent = c.maneuver
110
+ ? `${c.maneuver === "tack" ? "Tack" : "Gybe"} to ${c.toTack ?? "?"}`
111
+ : c.sailState;
112
+ const when = document.createElement("span");
113
+ when.className = "muted";
114
+ when.textContent = c.maneuver
115
+ ? `~${c.stamp}${c.twsKnots != null ? `, ${c.twsKnots.toFixed(0)} kt` : ""}`
116
+ : `${c.night ? "☾ " : ""}+${c.hoursFromNow}h ${c.stamp}`;
117
+ el.append(name, when);
118
+ wrap.appendChild(el);
119
+ }
120
+ this._actionsEl.appendChild(wrap);
121
+ }
122
+
123
+ // Energy deficit — only when true
124
+ const deficit = exceptions?.next24h?.energyDeficitAlert === true;
125
+ this._energyEl.innerHTML = "";
126
+ if (deficit) {
127
+ const el = document.createElement("div");
128
+ el.className = "banner";
129
+ el.textContent = "Energy deficit next 24h";
130
+ this._energyEl.appendChild(el);
131
+ }
132
+
133
+ // Hazard banners — only when present
134
+ const hazards = exceptions?.next24h?.hazards ?? [];
135
+ this._hazardsEl.innerHTML = "";
136
+ for (const h of hazards) {
137
+ const el = document.createElement("div");
138
+ el.className = "banner";
139
+ el.textContent = `⚠ ${h.description ?? h.noteId ?? "hazard"} +${h.hoursFromNow}h`;
140
+ this._hazardsEl.appendChild(el);
141
+ }
142
+ if (this._spaceEvents) {
143
+ this.setSpaceEvents(this._spaceEvents);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Tactical space-weather banners (work doc #3): aurora-class
149
+ * alerts only; comet items belong to the strategic outlook.
150
+ *
151
+ * @param {Array<{description: string}>|null} events
152
+ */
153
+ setSpaceEvents(events) {
154
+ this._spaceEvents = events;
155
+ if (!this._spaceEl) {
156
+ return; // Not yet connected
157
+ }
158
+ this._spaceEl.innerHTML = "";
159
+ for (const e of events ?? []) {
160
+ const el = document.createElement("div");
161
+ el.className = "banner";
162
+ el.textContent = `✦ ${e.description}`;
163
+ this._spaceEl.appendChild(el);
164
+ }
165
+ }
166
+ }
167
+
168
+ if (typeof customElements !== "undefined") {
169
+ customElements.define("tactical-dashboard", TacticalDashboard);
170
+ }
171
+
172
+ export { TacticalDashboard };