@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,294 @@
1
+ /**
2
+ * `<brief-ext-widget>` — the passage-brief plotter tile (work doc
3
+ * #8). Runs inside a chart plotter's sandboxed iframe (Plotter
4
+ * Extensions API v1 hosts): connects over the vendored bus client,
5
+ * subscribes to the plugin's three published paths through the
6
+ * host's multiplexed Signal K relay, and renders brief freshness.
7
+ * The pure state machine lives in `brief-ext-model.js`.
8
+ *
9
+ * Tap → open the full brief. The v1 widget→host open-panel request
10
+ * is not settled, so the documented fallback applies: open the brief
11
+ * webapp URL in a new browser context (the webapp renders from the
12
+ * cached payload, so it shows the same brief offline).
13
+ * Long-press → host config/remove dialog (pointer events inside the
14
+ * iframe are invisible to the host, so the widget detects the
15
+ * gesture itself). Night mode (optional capability) shifts the
16
+ * palette amber/red.
17
+ *
18
+ * @file brief-ext-widget.js
19
+ */
20
+
21
+ import { tileModel } from "./brief-ext-model.js";
22
+ import { connectExtension } from "./vendor/plotterext-bus/extension.js";
23
+
24
+ /** Flat paths the tile consumes (scalars over the host relay). */
25
+ const STREAM_PATHS = [
26
+ "navigation.briefing.generatedAt",
27
+ "navigation.briefing.route",
28
+ "navigation.briefing.hasNew",
29
+ "navigation.briefing.comfort",
30
+ "navigation.briefing.stale",
31
+ "navigation.briefing.ageHours",
32
+ ];
33
+
34
+ // Tap-to-open the full webapp is not possible from this iframe: the
35
+ // host sandbox blocks pop-ups and all top-frame navigation (verified
36
+ // on board), so the tile is a pure mini summary — the crew opens the
37
+ // briefing from the host app list or a host panel. The meta endpoint
38
+ // still runs on the same server, so the summary fetches directly.
39
+ const BRIEF_META_URL = "/plugins/signalk-passage-briefing/api/brief-meta";
40
+
41
+ const template = document.createElement("template");
42
+ template.innerHTML = /* html */ `
43
+ <style>
44
+ :host {
45
+ display: block;
46
+ box-sizing: border-box;
47
+ width: 100%;
48
+ height: 100%;
49
+ padding: 6px 8px;
50
+ background: var(--bg-panel, #111414);
51
+ border: 1px solid rgba(255, 255, 255, 0.1);
52
+ font-family: ui-monospace, "Fira Code", monospace;
53
+ color: var(--text-main, #ffffff);
54
+ cursor: default;
55
+ user-select: none;
56
+ --tile-accent: var(--color-teal, #4f9ea8);
57
+ }
58
+ :host(.ok) { --tile-accent: var(--color-teal, #4f9ea8); }
59
+ :host(.new) {
60
+ --tile-accent: var(--color-red, #c94b4b);
61
+ border-color: var(--color-red, #c94b4b);
62
+ }
63
+ :host(.muted) { --tile-accent: var(--color-grey, #666677); }
64
+
65
+ /* Night mode (host nightMode capability): amber/red palette. */
66
+ :host(.night) {
67
+ --color-teal: #c97b4f;
68
+ --color-red: #e06a5a;
69
+ --color-grey: #6a4a3f;
70
+ --text-main: #e8c9a0;
71
+ --bg-panel: rgba(22, 11, 6, 0.88);
72
+ }
73
+
74
+ .tile {
75
+ display: grid;
76
+ grid-template-rows: auto 1fr auto;
77
+ height: 100%;
78
+ gap: 2px;
79
+ }
80
+ .head {
81
+ display: flex;
82
+ justify-content: space-between;
83
+ align-items: center;
84
+ gap: 6px;
85
+ }
86
+ .head .label {
87
+ font-size: 0.55rem;
88
+ font-weight: 700;
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.1em;
91
+ color: var(--tile-accent);
92
+ white-space: nowrap;
93
+ }
94
+ .badge {
95
+ display: none;
96
+ padding: 1px 5px;
97
+ border: 1px solid var(--color-red, #c94b4b);
98
+ color: var(--color-red, #c94b4b);
99
+ font-size: 0.55rem;
100
+ font-weight: 700;
101
+ letter-spacing: 0.1em;
102
+ }
103
+ .badge.on { display: inline-block; }
104
+ .route {
105
+ align-self: center;
106
+ font-size: clamp(0.8rem, 3vh, 1.05rem);
107
+ font-weight: 700;
108
+ line-height: 1.15;
109
+ overflow: hidden;
110
+ display: -webkit-box;
111
+ -webkit-line-clamp: 2;
112
+ -webkit-box-orient: vertical;
113
+ }
114
+ .age {
115
+ font-size: 0.6rem;
116
+ letter-spacing: 0.06em;
117
+ color: var(--text-muted, #9aa3ad);
118
+ white-space: nowrap;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ }
122
+ .age.stale { color: #fca847; }
123
+ .comfort {
124
+ align-self: center;
125
+ font-family: var(--font-data, ui-monospace, monospace);
126
+ font-size: clamp(0.9rem, 4vh, 1.4rem);
127
+ font-weight: 700;
128
+ letter-spacing: 0.1em;
129
+ line-height: 1.1;
130
+ }
131
+ </style>
132
+ <div class="tile">
133
+ <div class="head">
134
+ <span class="label">Brief</span>
135
+ <span class="badge" id="badge">NEW</span>
136
+ </div>
137
+ <div class="route" id="route">No brief</div>
138
+ <div class="comfort" id="comfort"></div>
139
+ <div class="age" id="age">not compiled yet</div>
140
+ </div>
141
+ `;
142
+
143
+ class BriefExtWidget extends HTMLElement {
144
+ constructor() {
145
+ super();
146
+ const root = this.attachShadow({ mode: "open" });
147
+ root.append(template.content.cloneNode(true));
148
+ // target="_top" lets the anchor escape this iframe when the host
149
+ // sandbox allows top navigation at all
150
+
151
+ /** @type {Record<string, unknown>} latest per-path bus values */
152
+ this.values = {};
153
+ this.connected = false;
154
+
155
+ // Long-press → host config/remove dialog; short tap → open the
156
+ // brief webapp (fallback until the v1 open-panel request lands).
157
+ /** @type {number|null} */
158
+ this.pressTimer = null;
159
+ /** @type {boolean} */
160
+ this.longPressed = false;
161
+ this.addEventListener("pointerdown", this.onPointerDown);
162
+ this.addEventListener("pointerup", this.onPointerUp);
163
+ this.addEventListener("pointercancel", this.onPointerUp);
164
+ this.addEventListener("pointerleave", this.onPointerUp);
165
+ this.addEventListener("contextmenu", (e) => e.preventDefault());
166
+ }
167
+
168
+ /**
169
+ * @param {PointerEvent} e
170
+ * @returns {void}
171
+ */
172
+ onPointerDown(e) {
173
+ if (!this.connected || e.button !== 0) return;
174
+ this.longPressed = false;
175
+ this.pressTimer = setTimeout(() => {
176
+ this.longPressed = true;
177
+ this.client?.call("ui.toggleConfigPanel").catch(() => {});
178
+ }, 1200);
179
+ }
180
+
181
+ /**
182
+ * @returns {void}
183
+ */
184
+ onPointerUp() {
185
+ if (this.pressTimer) {
186
+ clearTimeout(this.pressTimer);
187
+ this.pressTimer = null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * @returns {void}
193
+ */
194
+ async connectedCallback() {
195
+ if (this.connected) return;
196
+ let client;
197
+ try {
198
+ client = await connectExtension();
199
+ } catch {
200
+ // No host handshake (opened standalone while developing, or the
201
+ // host vanished): render the placeholder and give up.
202
+ this.render();
203
+ return;
204
+ }
205
+ this.connected = true;
206
+ this.client = client;
207
+
208
+ // Current values right away: deltas only travel on change, so
209
+ // without this the tile would wait for the next ticker emission
210
+ try {
211
+ const meta = await fetch(BRIEF_META_URL).then((r) => r.json());
212
+ Object.assign(this.values, {
213
+ "navigation.briefing.generatedAt": meta.generatedAt,
214
+ "navigation.briefing.route": meta.route,
215
+ "navigation.briefing.hasNew": meta.hasNew,
216
+ "navigation.briefing.comfort": meta.comfort,
217
+ "navigation.briefing.stale": meta.stale,
218
+ "navigation.briefing.ageHours": meta.ageHours,
219
+ });
220
+ this.render();
221
+ } catch (_error) {
222
+ // Meta unavailable: the subscription still fills values in
223
+ }
224
+
225
+ try {
226
+ await client.signalk.subscribe(STREAM_PATHS, (ev) => {
227
+ // Event name is `sk.<path>`; the path is the dict key.
228
+ const path = ev?.path;
229
+ if (typeof path === "string") this.values[path] = ev.value;
230
+ this.render();
231
+ });
232
+ } catch {
233
+ // Host without signalk.stream (or relay failure): placeholders.
234
+ }
235
+
236
+ if (client.hasCapability("nightMode")) {
237
+ try {
238
+ const { enabled } = await client.nightMode.get();
239
+ this.classList.toggle("night", Boolean(enabled));
240
+ } catch {
241
+ /* best-effort seed */
242
+ }
243
+ await client
244
+ .subscribe(["nightMode.changed"], (_name, params) => {
245
+ this.classList.toggle("night", Boolean(params?.enabled));
246
+ })
247
+ .catch(() => {});
248
+ }
249
+
250
+ this.render();
251
+ }
252
+
253
+ disconnectedCallback() {
254
+ this.onPointerUp();
255
+ this.client?.close();
256
+ this.client = null;
257
+ this.connected = false;
258
+ }
259
+
260
+ /**
261
+ * Applies the latest bus values to the DOM.
262
+ *
263
+ * @returns {void}
264
+ */
265
+ render() {
266
+ const model = tileModel({
267
+ generatedAt: this.values["navigation.briefing.generatedAt"] ?? null,
268
+ route: this.values["navigation.briefing.route"] ?? null,
269
+ hasNew: this.values["navigation.briefing.hasNew"] ?? null,
270
+ });
271
+ const root = this.shadowRoot;
272
+ this.classList.remove("ok", "new", "muted");
273
+ this.classList.add(model.severity);
274
+ root.querySelector("#route").textContent = model.title;
275
+ const comfort = this.values["navigation.briefing.comfort"];
276
+ const comfortEl = root.querySelector("#comfort");
277
+ comfortEl.textContent = comfort ? String(comfort).toUpperCase() : "";
278
+ comfortEl.style.color =
279
+ {
280
+ champagne: "#8dfcbb",
281
+ easy: "#8dfcbb",
282
+ coffee: "#fca847",
283
+ rough: "#fca847",
284
+ sick: "#ff5e5e",
285
+ }[String(comfort).toLowerCase()] ?? "var(--text-main, #c4c4c4)";
286
+ const stale = this.values["navigation.briefing.stale"] === true;
287
+ const ageEl = root.querySelector("#age");
288
+ ageEl.textContent = model.detail + (stale ? " · STALE" : "");
289
+ ageEl.classList.toggle("stale", stale);
290
+ root.querySelector("#badge").classList.toggle("on", model.badge);
291
+ }
292
+ }
293
+
294
+ customElements.define("brief-ext-widget", BriefExtWidget);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Logbook backfill controls: a card on the strategic screen that
3
+ * triggers the plugin's POST /api/backfill from the browser session.
4
+ * The route is auth-gated server-side, so it cannot be curl-ed from
5
+ * the host without a login — the webapp's session is the interface.
6
+ *
7
+ * @file components/backfill-controls.js
8
+ */
9
+
10
+ import { fetchJson } from "./sk-api.js";
11
+ import { SK_BASE_CSS } from "./sk-base-css.js";
12
+
13
+ const PLUGIN_API = "/plugins/signalk-passage-briefing/api";
14
+
15
+ class BackfillControls 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
+ details.sk-card { margin-bottom: 12px; }
25
+ summary { cursor: pointer; font-weight: 700; }
26
+ .body { margin-top: 8px; }
27
+ .row {
28
+ display: flex; gap: 8px; flex-wrap: wrap;
29
+ align-items: center; margin-top: 8px;
30
+ }
31
+ input { font: inherit; }
32
+ .note { color: var(--color-grey); font-size: 0.85rem; margin: 4px 0 0; }
33
+ .out {
34
+ font-family: var(--font-data, ui-monospace, monospace);
35
+ font-size: 0.85rem; margin-top: 8px; white-space: pre-wrap;
36
+ }
37
+ .out.err { color: var(--color-red); }
38
+ </style>
39
+ <details class="sk-card">
40
+ <summary>Logbook Backfill</summary>
41
+ <div class="body">
42
+ <p class="note">
43
+ Learn sail preferences from signalk-logbook history into the
44
+ day/night matrix (optional date range).
45
+ </p>
46
+ <div class="row">
47
+ <label>From <input type="date" id="from"></label>
48
+ <label>To <input type="date" id="to"></label>
49
+ <button id="run">Backfill</button>
50
+ </div>
51
+ <div class="out" id="out"></div>
52
+ </div>
53
+ </details>
54
+ `;
55
+ this._run = this.shadowRoot.getElementById("run");
56
+ this._out = this.shadowRoot.getElementById("out");
57
+ this._run.addEventListener("click", () => this.backfill());
58
+ }
59
+
60
+ disconnectedCallback() {
61
+ this._run?.replaceWith(this._run.cloneNode(true));
62
+ }
63
+
64
+ async backfill() {
65
+ const from = this.shadowRoot.getElementById("from").value;
66
+ const to = this.shadowRoot.getElementById("to").value;
67
+ const query = new URLSearchParams();
68
+ if (from) {
69
+ query.set("from", from);
70
+ }
71
+ if (to) {
72
+ query.set("to", to);
73
+ }
74
+ const qs = query.size > 0 ? `?${query}` : "";
75
+ this._run.disabled = true;
76
+ this._out.classList.remove("err");
77
+ this._out.textContent = "Backfill running…";
78
+ try {
79
+ const summary = await fetchJson(`${PLUGIN_API}/backfill${qs}`, 600000, {
80
+ method: "POST",
81
+ });
82
+ const skipped = summary.skippedCached + summary.skippedNoData;
83
+ this._out.textContent =
84
+ `Learned ${summary.learned} of ${summary.total} sail changes` +
85
+ (skipped > 0 ? ` (skipped ${skipped})` : "") +
86
+ `, ${summary.cells.length} matrix cells`;
87
+ } catch (error) {
88
+ this._out.classList.add("err");
89
+ this._out.textContent = `Backfill failed: ${error.message}`;
90
+ } finally {
91
+ this._run.disabled = false;
92
+ }
93
+ }
94
+ }
95
+
96
+ if (typeof customElements !== "undefined") {
97
+ customElements.define("backfill-controls", BackfillControls);
98
+ }
99
+
100
+ export { BackfillControls };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The ℹ️ next to the comfort tier: an expanding explainer for the
3
+ * Sereno comfort scale (champagne → sick), highlighting the current
4
+ * tier via the `tier` attribute. Used by the tactical dashboard and
5
+ * the conditions-here view.
6
+ *
7
+ * @file components/comfort-info.js
8
+ */
9
+
10
+ import { COMFORT_SCALE_INFO } from "../sereno-physics.mjs";
11
+
12
+ class ComfortInfo extends HTMLElement {
13
+ static get observedAttributes() {
14
+ return ["tier"];
15
+ }
16
+
17
+ connectedCallback() {
18
+ if (!this.shadowRoot) {
19
+ this.attachShadow({ mode: "open" });
20
+ }
21
+ this.shadowRoot.innerHTML = `
22
+ <style>
23
+ :host { display: inline-block; position: relative; }
24
+ summary {
25
+ list-style: none; cursor: pointer; user-select: none;
26
+ }
27
+ summary::-webkit-details-marker { display: none; }
28
+ .pop {
29
+ position: absolute; z-index: 20; left: 0; top: calc(100% + 6px);
30
+ min-width: 16rem; max-width: 18rem; padding: 8px 10px;
31
+ background: var(--bg-panel);
32
+ border: 1px solid var(--color-grey);
33
+ font-family: var(--font-data, ui-monospace, monospace);
34
+ font-size: 0.75rem; text-transform: none; letter-spacing: normal;
35
+ }
36
+ .note { color: var(--color-grey); margin: 0 0 6px; }
37
+ .row { margin: 4px 0; }
38
+ .row.current { font-weight: 700; }
39
+ .row.current .name { text-decoration: underline; }
40
+ .band { color: var(--color-grey); display: block; }
41
+ </style>
42
+ <details class="info">
43
+ <summary aria-label="Comfort scale explained" title="Comfort scale explained">ℹ️</summary>
44
+ <div class="pop" id="pop"></div>
45
+ </details>
46
+ `;
47
+ this._details = this.shadowRoot.querySelector("details");
48
+ this._pop = this.shadowRoot.getElementById("pop");
49
+ this.renderPop();
50
+ }
51
+
52
+ attributeChangedCallback() {
53
+ this.renderPop();
54
+ }
55
+
56
+ renderPop() {
57
+ if (!this._pop) {
58
+ return;
59
+ }
60
+ const current = this.getAttribute("tier")?.toLowerCase() ?? "";
61
+ this._pop.textContent = "";
62
+ const note = document.createElement("p");
63
+ note.className = "note";
64
+ note.textContent =
65
+ "Comfort tiers are lost when apparent wind or vertical motion crosses their line — the worse factor wins.";
66
+ this._pop.appendChild(note);
67
+ for (const level of COMFORT_SCALE_INFO) {
68
+ const row = document.createElement("div");
69
+ row.className =
70
+ level.tier.toLowerCase() === current ? "row current" : "row";
71
+ const name = document.createElement("span");
72
+ name.className = "name";
73
+ name.textContent = level.tier.toUpperCase();
74
+ const text = document.createElement("span");
75
+ text.textContent = ` — ${level.text}`;
76
+ row.appendChild(name);
77
+ row.appendChild(text);
78
+ const band = document.createElement("span");
79
+ band.className = "band";
80
+ band.textContent =
81
+ level.maxAws == null
82
+ ? "wind ≥ 33 kn or motion ≥ 1.25 m/s²"
83
+ : `wind < ${level.maxAws} kn · motion < ${level.maxAz} m/s²`;
84
+ row.appendChild(band);
85
+ this._pop.appendChild(row);
86
+ }
87
+ }
88
+ }
89
+
90
+ if (typeof customElements !== "undefined") {
91
+ customElements.define("comfort-info", ComfortInfo);
92
+ }
93
+
94
+ export { ComfortInfo };