@meri-imperiumi/signalk-dead-reckoning 0.13.4 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.1] - 2026-10-19
11
+
12
+ ### Added
13
+ - **Warning region shapes on the chart (signalk-passage-briefing work doc #48).** The passage-briefing plugin now publishes METAREA and CAP warning areas as `regions` resources (native Polygon/MultiPolygon features paired with their point notes) — the chart draws them: fetched with the same view-scoped query as the notes (`/signalk/v2/api/resources/regions`, matched by geometry-bbox distance), rendered on a "Regions" layers-control toggle mounted by default, in the plugin console's visual language — severity red for `GALE`/`HURRICANE`/`DENSE FOG`/… keyword texts (the shared highlighter), bulletin orange otherwise, heavier stroke for severe, and a dashed outline for the briefing plugin's own bbox-approximate areas (the honesty cue that the shape is the queried bbox). Rings unwrap against the view's longitude frame so a METAREA spanning the dateline draws as one polygon, and both published geometry shapes are accepted (standard-nested and the bulletin engine's flat coordinate chains). A note whose region shape draws stays hidden — the extent renders once — and clicking a shape opens the pick menu at the clicked point with the href-paired note's detail (title, body, Edit/Delete), the same surface as clicking the note marker.
14
+ - **The chart pick menu shows the clicked coordinates** (helm request): the picked position renders in the watchkeeper's own position format (decimal/DM/DMS, shared with the sight panel) above the bearing and distance rows.
15
+
16
+ ### Fixed
17
+ - **Radar-range chart picks seed the clicked object's coordinates again.** `seedObjectPosition` looked the coord fieldset up at shadow-root level, and both the vertical (distance-CPL) and radar forms carry a `data-prefix="center"` fieldset — the query returned the vertical form's (first in document order), so a "Radar range at" pick switched to the radar tab but filled the hidden distance-CPL form, leaving the radar form's Object position empty. The lookup is now scoped to the switched-to form.
18
+
19
+ ### Changed
20
+ - **Chartplotter zoom and follow-mode handling (helm feedback).** Wheel zoom is now continuous and fast: Leaflet's stock ScrollWheelZoom — which quantizes each debounced wheel burst to whole zoom levels and plays an animation per level (a jerky staircase), and whose sigmoid curve is meaningless under fractional zoom, making a detent move the chart ~0.01 levels — is replaced by a custom rAF-batched handler: wheel deltas accumulate per animation frame and apply as an un-animated fractional zoom at stock Leaflet speed (~one level per detent) with no jumps and no debounce lag. The anchor is follow-aware across every zoom gesture and locked to the BOAT'S POSITION rather than the map center: the wheel handler re-centers each animation frame on the DR position (the center can drift from the vessel between ticks — keyboard pans never fire dragstart, extensions may recenter — and anchoring the center would freeze that drift for the whole gesture, which read as "zoom snaps to whatever was the map center"), while pinch snaps the boat to center at touchstart (before Leaflet captures the gesture anchor) and double-click snaps back after Leaflet's center jump; with follow off the gestures anchor at the cursor as before, and follow is authoritative over the view while engaged: the ◎ re-center pans to the follow target un-animated (immediately) — the live DR position, falling back to the GNSS fix while the DR engine is idle so the button never engages into a silent no-op, with the camera locked onto DR once its positions flow (`vm.followTarget`: a live DR position always wins, so GPS fixes ticking faster than the DR engine cannot drag the view between the two marks; a stale DR position transfers to the fix exactly once, and DR resuming takes the camera back), the keyboard pan handler stands down while following (arrow pans never fire dragstart, so the view would otherwise drift off the vessel and snap back at the next DR tick), and the plotter-extension `map.center`/`map.fitBounds` affordances are refused with a `VIEW_LOCKED` error while follow is engaged (the host reads the lock through a dedicated `getFollow` accessor — `getMap` keeps returning the raw Leaflet map the overlay machinery hooks) — freeform view moves happen only once follow is released. Also moved `customElements.define("dr-map-view")` after the `NauticalScaleControl` class declaration — the synchronous upgrade of an in-DOM element runs `connectedCallback` → `initMap` → `new NauticalScaleControl()`, which threw "Cannot access before initialization" on any page where the element exists before the module evaluates (found by driving the map headless). Follow mode is now **off by default** — the map still opens centered on the vessel (one-shot fit on the first GPS fix, or the first DR position when no fix has arrived) but the helm keeps control of the view. The ◎ button is a real toggle: click to engage follow (and pan to the DR position, clearing the settle window so it always takes effect at once), click again to let go; dragging the chart lets go as before. The zoom-dependent re-renders (range rings, laylines, downstream note refetch) run on a trailing debounce instead of per zoom step.
21
+
22
+ ## [0.14.0] - 2026-10-18
23
+
24
+ ### Added
25
+ - **The DR webapp hosts chart layers: plotter-extension map overlays (work doc #27, chart-layer phase).** The webapp's plotter-extension host — so far widgets and panels only — gains the surface a map layer needs, with [signalk-livedepth-plugin](https://github.com/night199uk/signalk-livedepth-plugin) as the first consumer: soundings recorded by the LiveDepth plugin draw as a shaded, isobath-contoured depth surface on the DR chart, live-extended while sailing. New host capabilities: `background.iframe` (headless 1×1 sandboxed iframes running extension runtimes off-viewport, torn down with the extension), `overlays.image` (the `overlay.request` → `overlay.frame` cycle — the host sends the viewport's EPSG:3857 extent, bounds, pixel ratio clamped to the overlay's `preferredPixelRatio`, resolution, zoom and `liveInterval`; the runtime answers with a transferred `ImageBitmap` the host composites into a pane above the charts and below the chartwork/vessel, refusing `overlays.superseded` frames and pacing live ones at `overlays.throttled`; frames stay georeferenced through panning via their own extent, and world copies on either side of the antimeridian are covered), `map` (`map.getView`/`map.center`/`map.fitBounds` plus settled `map.view` events), `units` (`units.get` with the preferred depth unit, default `m`), and `buttons` (`mapToolbar` entries rendered into a stack above the bottom-left controls, toggling panels). Layer affordances: a ▤ **Plugin layers** control opens a dialog with per-layer show/hide, opacity, and the layer's settings panel (`configPanel`) — layer state persists in `dr.plotterext.overlays.v1` like the chart layers' own prefs, extensions' `state.get/set`/`state.changed` storage shared with their settings panel, and `events.publish` routing (scope `all`/`extension`). The vendored `signalk-plotterext-bus` moves 0.11.0 → 0.21.0 for the `overlay.*` and `map.view` wire surface (0.12–0.18 lack it). Nothing is needed server-side: LiveDepth registers its own `plotterExtensions` provider, and the host discovers it through the same collection polling; the same `overlays.image` surface is what a future radar-overlay provider would plug into for DR fixes.
26
+
27
+ ### Fixed
28
+ - **The GPS/override bar no longer sits in the middle of a phone screen** (`public/dr-ext-host.js`): on the ≤600 px phone layout the top band stacks vertically, and every `<dr-ext-widget-area>` reserves its full 2×2 widget footprint even while empty — so the empty top-left area inflated the tools column to ~323 px and pushed the GPS status + Engage OVERRIDE panel down to y≈337 of an 844 px viewport, mid-screen. The reservation exists only for the chart context-menu placement hit-test, so it now collapses inside the area's own phone media query; a placed grid still sizes itself through its rows, and placement simply isn't offered on an empty phone area. Phone top band drops from ~656 px to ~260 px with the GPS bar back at the top. `doc/dr-mobile.png` regenerated with the fixed layout.
29
+
30
+ ### Changed
31
+ - **Passage replay gains a blind variant — the GPS-loss backtest the tier-2 circularity demanded** (`tools/replay-passage.js`): the derived tier-2 current is fitted to the very GPS track a replay scores against, so "derived beats everything" over a fully-GPS-fed passage is a tautology, not a result. The new `--blind-from ISO` variant simulates the actual use case — GPS dies mid-passage: fixes stop feeding the derived-current state at the cutoff while snapshots keep being taken, so the shadow boat navigates on the carried EWMA current decaying at its 24 h time constant and lapsing to tier 5 after the TTL, with divergence still scored against the hidden GPS track. First blind run on the Niue→Vava'u passage (2026-09-11→14, 253 nm, GPS lost at hour 24 of 60): DR stayed within ~10 nm of truth through the entire 24 h carry window, then degraded at ~1 nm/h once the current support vanished — 22.5 nm final vs 10.0 nm with GPS live and 38.2 nm cold. Also fixed on the way: the derived-current provider returned no `tier` field, so the §6.2 training gate read every tick as unresolved and the learning variant silently never trained (0 matrix bins, learn ≡ derived); it now stamps `tier: 2`, the leeway-synthetic test's threshold is recalibrated to the post-gate semantics (the old bound was met by the current, not by learning), and the `scudLearn` variant is vestigial by design — the gate deliberately blocks training on model currents.
32
+
10
33
  ## [0.13.4] - 2026-10-16
11
34
 
12
35
  ### Added
package/README.md CHANGED
@@ -6,13 +6,14 @@ An offline-first dead reckoning and sensor fusion engine for Signal K that maint
6
6
 
7
7
  ## Why
8
8
 
9
- Planning for life without GPS is no longer hypothetical: the UK Maritime and Coastguard Agency's Guidance Note 719 ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct 2025) reports a marked increase in GNSS jamming and spoofing worldwide and asks mariners to review contingency arrangements, maintain resilient navigation practices, and practice navigating without satellite positioning — naming dead reckoning, visual bearings, and celestial fixing as valid methods. This plugin implements exactly that contingency for Signal K vessels: the fallback is always running, and the trusted-GPS period is spent training it.
9
+ Planning for life without GPS is no longer hypothetical: the UK Maritime and Coastguard Agency's [Guidance Note 719](https://www.gov.uk/government/publications/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments) ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct 2026) reports a marked increase in GNSS jamming and spoofing worldwide and asks mariners to review contingency arrangements, maintain resilient navigation practices, and practice navigating without satellite positioning — naming dead reckoning, visual bearings, and celestial fixing as valid methods. This plugin implements a contingency for Signal K vessels.
10
10
 
11
11
  ## Requirements
12
12
 
13
13
  * This plugin installed and configured
14
14
  * Some chart source available to Signal K
15
15
  * Hand bearing compass and/or sextant
16
+ * A heading source independent of GNSS — a fluxgate or inertial compass feeding `navigation.headingMagnetic`, not a satellite (GNSS) compass. A satellite compass shares the jamming and spoofing failure mode of the position fix, so a vessel whose only heading source is satellite-derived cannot dead-reckon through GNSS denial. Be careful checking this: on N2K networks a single physical device often provides both compass and position data, and display list entries do not necessarily reflect that (a device may publish both PGN 127257 heading and PGN 129029 position under one source)
16
17
 
17
18
  ## Getting started
18
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.4",
3
+ "version": "0.14.1",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/public/dr-app.js CHANGED
@@ -140,10 +140,7 @@ template.innerHTML = /* html */ `
140
140
  look, the glance gets the gist. */
141
141
  .dr-status {
142
142
  margin: 0;
143
- max-width: 22rem;
144
143
  white-space: nowrap;
145
- overflow: hidden;
146
- text-overflow: ellipsis;
147
144
  font-family: ui-monospace, "Fira Code", monospace;
148
145
  font-size: 0.75rem;
149
146
  color: var(--text-muted);
@@ -158,8 +155,7 @@ template.innerHTML = /* html */ `
158
155
  --theme-color: var(--color-green);
159
156
  color: var(--color-green);
160
157
  }
161
- .dr-status.alert,
162
- .dr-status.retrying {
158
+ .dr-status.alert {
163
159
  --theme-color: var(--color-red);
164
160
  color: var(--color-red);
165
161
  font-weight: 600;
@@ -179,29 +175,44 @@ template.innerHTML = /* html */ `
179
175
  background: var(--color-teal);
180
176
  color: var(--bg-base);
181
177
  }
182
- /* Failover control — top-right panel, inline with the status
183
- badge: one row, same vertical size as the tools panel. Alternate-
184
- power semantics: orange, red when engaged (DR authoritative).
185
- The button keeps its minimum width — SPEC §14.1: prominent,
186
- always human-initiated. */
178
+ /* Stream link badge — [ONLINE]/[OFFLINE], color-coded; the full
179
+ connection detail (reconnecting…, which endpoint) rides the
180
+ badge's tooltip. */
181
+ .dr-link {
182
+ font-family: ui-monospace, "Fira Code", monospace;
183
+ font-size: 0.75rem;
184
+ color: var(--color-orange);
185
+ }
186
+ .dr-link.online {
187
+ color: var(--color-green);
188
+ }
189
+ .dr-link.offline {
190
+ color: var(--color-red);
191
+ font-weight: 600;
192
+ }
193
+ /* Failover control — top-right panel, one compact badge row:
194
+ [ONLINE] DR[ON] GNSS[OK] [OVERRIDE]. Alternate-power semantics:
195
+ orange, red when engaged (DR authoritative). The button stays
196
+ prominent (SPEC §14.1: always human-initiated) without eating
197
+ the top bar. */
187
198
  .dr-gps {
188
199
  display: flex;
189
200
  flex-direction: row;
190
201
  align-items: center;
191
202
  justify-content: flex-end;
192
- gap: 0.75rem;
203
+ gap: 0.5rem;
193
204
  flex-wrap: wrap;
194
205
  }
195
206
  .dr-override {
196
207
  display: flex;
197
208
  align-items: center;
198
209
  justify-content: flex-end;
199
- gap: 0.75rem;
210
+ gap: 0.4rem;
200
211
  flex-wrap: wrap;
201
212
  }
202
213
  .dr-override button {
203
214
  --theme-color: var(--color-orange);
204
- min-width: 13rem;
215
+ min-width: 5.5rem;
205
216
  }
206
217
  .dr-override button.engaged,
207
218
  .dr-override button.engaged:hover,
@@ -218,6 +229,9 @@ template.innerHTML = /* html */ `
218
229
  letter-spacing: 0.08em;
219
230
  color: var(--text-muted);
220
231
  }
232
+ .dr-override #dr-override-state.off {
233
+ color: var(--color-orange);
234
+ }
221
235
  /* Pending-observations drawer: docked left, below the entry
222
236
  tools; the list scrolls inside, the panel never grows past the
223
237
  viewport. The whole box (toggle included) disappears when there
@@ -357,7 +371,6 @@ template.innerHTML = /* html */ `
357
371
  }
358
372
  .dr-status {
359
373
  font-size: 0.7rem;
360
- max-width: 14rem;
361
374
  }
362
375
  .dr-override button {
363
376
  min-width: 11rem;
@@ -421,19 +434,20 @@ template.innerHTML = /* html */ `
421
434
  <div class="dr-top-col">
422
435
  <section class="sk-floating dr-tools">
423
436
  <button id="btn-sight">⊕ Sight / LOP</button>
424
- <button id="btn-coord-fix" title="Confirm a fix at coordinates — prefilled from the current GNSS position, editable for offline/known-position fixes">⊙ Fix at coordinates</button>
437
+ <button id="btn-coord-fix" title="Fix at coordinates — prefilled from the current GNSS position, editable for offline/known-position fixes">⊙ FIX AT</button>
425
438
  <button id="btn-pending" aria-expanded="false" aria-controls="dr-pending-drawer" hidden>◧ Pending</button>
426
439
  </section>
427
440
  <dr-ext-widget-area anchor="top-left"></dr-ext-widget-area>
428
441
  </div>
429
442
  <div class="dr-top-col dr-top-col-right">
430
443
  <section class="sk-floating dr-gps">
431
- <div class="dr-status" id="dr-status-panel">
432
- <span id="dr-status-text">Connecting to Signal K…</span>
444
+ <span class="dr-link" id="dr-link" title="Connecting to Signal K…">[CONNECTING…]</span>
445
+ <div class="dr-status" id="dr-status-panel" title="No dead-reckoning data">
446
+ <span id="dr-status-text">DR[—]</span>
433
447
  </div>
434
448
  <div class="dr-override">
435
- <button id="dr-override-btn">Engage OVERRIDE</button>
436
- <span id="dr-override-state">NORMAL (GPS authoritative)</span>
449
+ <span id="dr-override-state" title="Normal — the GNSS fix is authoritative">GNSS[OK]</span>
450
+ <button id="dr-override-btn" title="Toggle DR-authoritative override — while engaged the dead-reckoned position steers the chart and the GNSS fix is not trusted">OVERRIDE</button>
437
451
  </div>
438
452
  </section>
439
453
  <dr-ext-widget-area anchor="top-right"></dr-ext-widget-area>
@@ -783,6 +797,11 @@ class DrApp extends HTMLElement {
783
797
  * source of truth is the server's v2 resources API; the cache lets
784
798
  * saves update markers without a full refetch. */
785
799
  this.notesById = new Map();
800
+ /** Signal K regions cache (passage-briefing work doc #48): id →
801
+ * region resource. The warning shapes published alongside the
802
+ * notes — fetched with the same view-scoped query, drawn on the
803
+ * chart as polygon extents. */
804
+ this.regionsById = new Map();
786
805
  /** Bounds the last in-view notes fetch covered (fetchNotesInView
787
806
  * margin guard) — null until the first view-driven fetch.
788
807
  * @type {{west: number, south: number, east: number, north: number}|null} */
@@ -903,7 +922,19 @@ class DrApp extends HTMLElement {
903
922
  this.extHost = new PlotterExtHost({
904
923
  stream: window.drSignalkStream,
905
924
  mount: root,
925
+ // The chart, once it exists — map overlays composite onto it and
926
+ // the map capability reads/drives its viewport.
927
+ getMap: () => this.map?.map ?? null,
928
+ getFollow: () => this.map?.follow ?? false,
906
929
  });
930
+ // The host hooks the Leaflet events itself, but discovery can land
931
+ // before the chart exists — forward the element's own view-change
932
+ // event and kick once so the first overlay frames are requested
933
+ // even without a user pan.
934
+ this.map?.addEventListener("dr-view-changed", () =>
935
+ this.extHost.viewChanged(),
936
+ );
937
+ setTimeout(() => this.extHost.viewChanged(), 250);
907
938
  for (const el of root.querySelectorAll("dr-ext-widget-area")) {
908
939
  this.extHost.attachArea(el);
909
940
  }
@@ -1245,15 +1276,32 @@ class DrApp extends HTMLElement {
1245
1276
  async fetchNotes(view) {
1246
1277
  try {
1247
1278
  const query = view ? `?${vm.notesViewQuery(view)}` : "";
1248
- const res = await fetch(`/signalk/v2/api/resources/notes${query}`);
1249
- if (!res.ok) return;
1250
- const collection = await res.json();
1251
- if (view) {
1252
- for (const [id, note] of Object.entries(collection ?? {})) {
1253
- this.notesById.set(id, note);
1279
+ const [notesRes, regionsRes] = await Promise.all([
1280
+ fetch(`/signalk/v2/api/resources/notes${query}`),
1281
+ fetch(`/signalk/v2/api/resources/regions${query}`),
1282
+ ]);
1283
+ if (notesRes.ok) {
1284
+ const collection = await notesRes.json();
1285
+ if (view) {
1286
+ for (const [id, note] of Object.entries(collection ?? {})) {
1287
+ this.notesById.set(id, note);
1288
+ }
1289
+ } else {
1290
+ this.notesById = new Map(Object.entries(collection ?? {}));
1291
+ }
1292
+ }
1293
+ // Regions ride the same view-scoped query: the regions provider
1294
+ // matches by geometry-bbox distance (a region has no position
1295
+ // of its own — the paired note is the queryable anchor).
1296
+ if (regionsRes.ok) {
1297
+ const collection = await regionsRes.json();
1298
+ if (view) {
1299
+ for (const [id, region] of Object.entries(collection ?? {})) {
1300
+ this.regionsById.set(id, region);
1301
+ }
1302
+ } else {
1303
+ this.regionsById = new Map(Object.entries(collection ?? {}));
1254
1304
  }
1255
- } else {
1256
- this.notesById = new Map(Object.entries(collection ?? {}));
1257
1305
  }
1258
1306
  this.renderNotes();
1259
1307
  } catch {
@@ -1285,8 +1333,16 @@ class DrApp extends HTMLElement {
1285
1333
 
1286
1334
  /** @returns {void} */
1287
1335
  renderNotes() {
1288
- const collection = Object.fromEntries(this.notesById);
1289
- this.map?.renderNotes(vm.notesRenderSpecs(collection), this.notesById);
1336
+ const notes = Object.fromEntries(this.notesById);
1337
+ const regions = Object.fromEntries(this.regionsById);
1338
+ const referenceLon = this.map?.map?.getCenter()?.lng ?? null;
1339
+ const shapes = vm.regionShapes(regions, referenceLon);
1340
+ this.map?.renderRegions(shapes, this.regionsById);
1341
+ // A note whose region shape draws stays hidden: the region carries
1342
+ // the warning's true extent, the note is its anchored detail — the
1343
+ // pair renders once (href-linked, `*-area-` ids).
1344
+ const drawn = new Set(shapes.map((shape) => shape.id));
1345
+ this.map?.renderNotes(vm.notesRenderSpecs(notes, drawn), this.notesById);
1290
1346
  }
1291
1347
 
1292
1348
  /**
@@ -1953,18 +2009,21 @@ class DrApp extends HTMLElement {
1953
2009
  renderOverride(active) {
1954
2010
  if (!this.btn || !this.stateLabel) return;
1955
2011
  this.btn.classList.toggle("engaged", active);
1956
- this.btn.textContent = active ? "Release OVERRIDE" : "Engage OVERRIDE";
1957
- this.stateLabel.textContent = active
1958
- ? "OVERRIDE (DR authoritative)"
1959
- : "NORMAL (GPS authoritative)";
2012
+ this.stateLabel.classList.toggle("off", active);
2013
+ this.stateLabel.textContent = active ? "GNSS[OFF]" : "GNSS[OK]";
2014
+ this.stateLabel.title = active
2015
+ ? "Override engaged — the dead-reckoned position is authoritative; the GNSS fix is not trusted"
2016
+ : "Normal — the GNSS fix is authoritative";
1960
2017
  }
1961
2018
 
1962
2019
  /**
1963
- * Renders the DR engine state — explains why the readout is empty
1964
- * when idle (moored, no speed/heading) so the user isn't left
1965
- * guessing.
2020
+ * Renders the DR engine state as a compact badge (DR[ON], DR[IDLE],
2021
+ * DR[FOULED]…) with the full explanatory text on the badge's
2022
+ * tooltip — the helm reads the row at a glance and hovers for the
2023
+ * why.
1966
2024
  *
1967
- * @param {{status: string, reason?: string}|null} value
2025
+ * @param {{status: string, reason?: string, moving?: boolean,
2026
+ * fouled?: boolean, transient?: boolean}|null} value
1968
2027
  * @returns {void}
1969
2028
  */
1970
2029
  renderDrState(value) {
@@ -1972,43 +2031,54 @@ class DrApp extends HTMLElement {
1972
2031
  const text = this.shadowRoot.querySelector("#dr-status-text");
1973
2032
  if (!panel || !text) return;
1974
2033
  panel.classList.remove("idle", "underway", "alert", "transient");
1975
- if (!value) {
1976
- text.textContent = "No dead-reckoning data";
1977
- return;
2034
+ let title = "No dead-reckoning data";
2035
+ if (value) {
2036
+ if (value.status === "idle") {
2037
+ panel.classList.add("idle");
2038
+ if (value.moving) panel.classList.add("alert");
2039
+ } else if (value.status === "underway") {
2040
+ panel.classList.add("underway");
2041
+ if (value.fouled) panel.classList.add("alert");
2042
+ else if (value.transient) panel.classList.add("transient");
2043
+ } else if (value.status === "warm" && value.fouled) {
2044
+ // The engine runs warm on a tied-up boat (SPEC §5) — muted,
2045
+ // unless the speed sensor is crying wolf.
2046
+ panel.classList.add("alert");
2047
+ }
2048
+ title =
2049
+ vm.drStatusText({ ...value, navState: this.vesselNavState }) ?? title;
1978
2050
  }
1979
- if (value.status === "idle") {
1980
- panel.classList.add("idle");
1981
- if (value.moving) panel.classList.add("alert");
1982
- } else if (value.status === "underway") {
1983
- panel.classList.add("underway");
1984
- if (value.fouled) panel.classList.add("alert");
1985
- else if (value.transient) panel.classList.add("transient");
1986
- } else if (value.status === "warm") {
1987
- // Engine alive, boat tied up — muted, no status color crying wolf.
1988
- if (value.fouled) panel.classList.add("alert");
1989
- }
1990
- const next = vm.drStatusText({ ...value, navState: this.vesselNavState });
1991
- if (next != null) text.textContent = next;
2051
+ text.textContent = vm.drStatusBadge(value);
2052
+ panel.title = title;
1992
2053
  }
1993
2054
 
1994
2055
  /**
1995
- * Renders the stream link state so a dropped connection is visible
1996
- * immediately.
2056
+ * Renders the stream link state as a compact [ONLINE]/[OFFLINE]
2057
+ * badge (color-coded) with the full connection detail on its
2058
+ * tooltip — a dropped connection is visible immediately without
2059
+ * eating the top bar.
1997
2060
  *
1998
2061
  * @param {{state: "connecting"|"open"|"retrying"}} status
1999
2062
  * @returns {void}
2000
2063
  */
2001
2064
  renderLinkStatus(status) {
2002
- const panel = this.shadowRoot.querySelector("#dr-status-panel");
2003
- const text = this.shadowRoot.querySelector("#dr-status-text");
2004
- if (!panel || !text) return;
2005
- panel.classList.remove("retrying");
2006
- if (status.state === "connecting") {
2007
- text.textContent = "Connecting to Signal K…";
2008
- } else if (status.state === "retrying") {
2009
- panel.classList.add("retrying");
2010
- text.textContent = "Signal K link lost — reconnecting…";
2011
- } else if (status.state === "open") {
2065
+ const link = this.shadowRoot.querySelector("#dr-link");
2066
+ if (link) {
2067
+ link.classList.remove("online", "offline");
2068
+ if (status.state === "open") {
2069
+ link.textContent = "[ONLINE]";
2070
+ link.classList.add("online");
2071
+ link.title = "Signal K stream connected";
2072
+ } else if (status.state === "retrying") {
2073
+ link.textContent = "[OFFLINE]";
2074
+ link.classList.add("offline");
2075
+ link.title = "Signal K link lost — reconnecting…";
2076
+ } else {
2077
+ link.textContent = "[CONNECTING…]";
2078
+ link.title = "Connecting to Signal K…";
2079
+ }
2080
+ }
2081
+ if (status.state === "open") {
2012
2082
  // Re-seed AIS static data on (re)connect (work doc #23) — names
2013
2083
  // and MMSIs don't ride position deltas.
2014
2084
  this.bootstrapAis();
@@ -2017,7 +2087,6 @@ class DrApp extends HTMLElement {
2017
2087
  // while offline) reappear without a page reload.
2018
2088
  this.fetchNotes();
2019
2089
  }
2020
- // "open" — let the DR state panel take over once data flows.
2021
2090
  }
2022
2091
  }
2023
2092