@meri-imperiumi/signalk-dead-reckoning 0.3.0 → 0.5.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 (36) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +36 -0
  3. package/package.json +1 -1
  4. package/plugin/index.js +131 -0
  5. package/plugin/plotterext.js +194 -0
  6. package/plugin/shadow-vessel.js +179 -0
  7. package/plugin/statustilesexamples.js +109 -0
  8. package/public/dr-app.js +115 -5
  9. package/public/dr-ext-model.js +84 -0
  10. package/public/dr-ext-widget.html +29 -0
  11. package/public/dr-ext-widget.js +275 -0
  12. package/public/dr-map-view.js +433 -29
  13. package/public/dr-sight-panel.js +27 -3
  14. package/public/dr-signalk-stream.js +69 -13
  15. package/public/dr-viewmodel.js +827 -2
  16. package/public/index.html +5 -0
  17. package/public/vendor/maplibre-gl/LICENSE-leaflet-maplibre-gl.txt +15 -0
  18. package/public/vendor/maplibre-gl/LICENSE-maplibre-gl.txt +116 -0
  19. package/public/vendor/maplibre-gl/README.md +19 -0
  20. package/public/vendor/maplibre-gl/leaflet-maplibre-gl.js +234 -0
  21. package/public/vendor/maplibre-gl/maplibre-gl.css +1 -0
  22. package/public/vendor/maplibre-gl/maplibre-gl.js +59 -0
  23. package/public/vendor/plotterext-bus/LICENSE +21 -0
  24. package/public/vendor/plotterext-bus/README.md +12 -0
  25. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +318 -0
  26. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +267 -0
  27. package/public/vendor/plotterext-bus/extension.js +31 -0
  28. package/status-tiles-examples.json +120 -0
  29. package/tests/dr-ais.test.js +447 -0
  30. package/tests/dr-ext-model.test.js +96 -0
  31. package/tests/fake-app.js +21 -0
  32. package/tests/plotterext.test.js +188 -0
  33. package/tests/shadow-vessel-integration.test.js +233 -0
  34. package/tests/shadow-vessel.test.js +209 -0
  35. package/tests/statustilesexamples.test.js +174 -0
  36. package/tests/vector-charts.test.js +527 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,117 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-08-28
9
+
10
+ ### Added
11
+ - **AIS targets on the DR chart (work doc #23)** — the DR webapp now
12
+ renders AIS traffic on the map and lets you take a bearing to a
13
+ vessel you can see, even in open water with no charted objects in
14
+ range. Targets arrive on the same stream under a second `vessels.*`
15
+ subscription (throttled to 2 s per path; static name/MMSI seeded from
16
+ a REST snapshot and re-seeded on reconnect), and render as a
17
+ plotter-style tri-state: active (violet arrow, green for AIS buddies,
18
+ rotated to heading/COG, with a 6-minute velocity leader), expiring
19
+ after 3 minutes without a report (grey, leader dropped, tooltip says
20
+ how old the report is), then aged out and evicted entirely after 20
21
+ minutes. Marks ride their dead-reckoned track between reports —
22
+ position is predicted forward from the last report along SOG/COG
23
+ (capped at the expiring threshold) — so right-click → "Bearing to
24
+ <name>" seeds the sight form from where the vessel actually is at
25
+ pick time, with the sight time defaulting to that same instant.
26
+ Targets are range-filtered around the own boat (default 24 nm),
27
+ own vessel and the DR shadow vessel are excluded, and an "AIS
28
+ traffic" checkbox in the layers control de-clutters the chart.
29
+ `GET /status` now reports the shadow vessel's context
30
+ (`shadowVesselContext`) so the webapp can filter it.
31
+
32
+ ## [0.4.0] - 2026-08-28
33
+
34
+ ### Added
35
+ - **Example Status Tiles set (work doc #22)** — the plugin now ships a
36
+ ready-made Status Tiles set (`status-tiles-examples.json`) that a
37
+ boat owner copies into their panel with one tap — no JSON editing,
38
+ no hand-authoring predicates. The set exposes the underway DR
39
+ integrity tile: the sensor-health and divergence-advisory
40
+ notifications, the DR-override (`navigation.deadReckoning.active`)
41
+ state, and banded checks on the uncertainty radius (1 nm warn / 3 nm
42
+ crit) and time-since-fix (3 h / 6 h), with footer readouts for
43
+ uncertainty, DR–GPS divergence, and the DR method. It is advertised
44
+ through the standard resources API as a read-only
45
+ `statusTileExamples` provider (keyed by the plugin id, returning `{}`
46
+ when stopped so a disabled plugin contributes no stale sets),
47
+ which the signalk-status-tiles webapp discovers and offers as a
48
+ one-tap "Add" that merges into the user's config (skipping existing
49
+ ids — idempotent, never overwrites user edits). Mirrors the existing
50
+ plotter-extension provider pattern; no running server is required to
51
+ ship or consume it.
52
+ - **Shadow vessel on chart plotters (work doc #21)** — the DR
53
+ shadow-boat position is now published as a synthetic vessel on a
54
+ stable `vessels.<uuid>` context, so chart plotters that source
55
+ vessels from the Signal K stream (Freeboard-SK's AIS layer) render
56
+ it on the chart alongside the own vessel and AIS traffic, with a
57
+ projected COG line. Freeboard-SK draws it as a green `ais_buddy`
58
+ target — the only per-target data-driven visual lever in its AIS
59
+ style decision tree is the `buddy` flag, which the plugin sets on the
60
+ root-value delta. The gap between the shadow's heading (bow) and its
61
+ COG line visualizes set + leeway, mirroring how the plotter treats
62
+ the own vessel. Opt-in via *Shadow Vessel* in the plugin settings
63
+ (off by default); the configurable label defaults to "DR Shadow".
64
+ The plugin stays the source of truth — the shadow is an additional
65
+ chart-visible target, not a navigational authority. The context UUID
66
+ is generated once and persisted so the plotter's target id is
67
+ continuous across restarts. While DR is idle (moored, no speed or
68
+ heading) the shadow keeps publishing the last position at SOG 0 so
69
+ it doesn't vanish or go stale, and the COG line clears. No
70
+ Freeboard-SK change is required.
71
+ - **Vector chart tiles in the webapp map (work doc #20)** — charts
72
+ with `format: 'pbf'` in the server's configured charts resource
73
+ (NOAA ENC-derived S-57 MBTiles, an Open Waters passage cache, any
74
+ vector MBTiles served by `signalk-charts-provider-simple`) now
75
+ render instead of failing silently as broken image tiles. MapLibre
76
+ GL JS 5.24.0 and the official Leaflet bridge are vendored under
77
+ `public/vendor/maplibre-gl/` (same policy as Leaflet — no CDN, no
78
+ build step) and mounted via `L.maplibreGL`, so all DR overlays stay
79
+ plain Leaflet. When the corridor downloader's asset manifest
80
+ (`/plugins/signalk-corridor-tile-downloader/assets/manifest.json`)
81
+ advertises a mirrored Open Waters `style` URL, that style is mounted
82
+ wholesale — full marine symbology (buoys by shape and colour,
83
+ lights, restricted-area hatching), bathymetry contours and soundings
84
+ with labels, base map and hillshade, online and offline alike —
85
+ while terrarium-DEM (`webp`) stores stay mirror internals and never
86
+ appear as overlays. For sources without a manifest the style is
87
+ composed client-side by the pure `maplibreStyleFor` (view-model):
88
+ one vector source on the chart's absolute tile URL (MapLibre's
89
+ blob-URL workers can't resolve relative paths) with native max zoom
90
+ — vector charts keep rendering past the raster maxNativeZoom
91
+ ceiling — plus a dark sea background and geometry-only
92
+ fill/line/circle layers with marine styling for known source-layer
93
+ families (S-57 LNDARE/DEPARE/DEPCNT/COALNE/SOUNDG ids and the
94
+ OSM-marine names the corridor cache carries: seamark, waterway,
95
+ wetland, sea_area, light as amber dots). Chart-mount failures are
96
+ logged instead of vanishing. Right-clicking a charted symbol
97
+ (lighthouse, seamark, peak, island) resolves its charted name and
98
+ seeds the sight form's *Object* field — bearings are taken to
99
+ identified objects, not bare coordinates.
100
+ - **Plotter-extension status tile for Freeboard-SK (work doc #19)** —
101
+ the plugin now registers a `plotterExtensions` resource provider
102
+ (Plotter Extensions API v1) so chart plotters that host the
103
+ mechanism (Freeboard-SK ≥ 3.0) offer a "Dead Reckoning" widget:
104
+ a 2×1 glanceable tile showing GPS↔DR divergence, the DR uncertainty
105
+ radius, fix cadence and DR health (tracking / maneuver / fouled /
106
+ stale / warm), colored by severity like the webapp status line, with
107
+ an OVERRIDE badge when DR is authoritative and an amber night-mode
108
+ palette following the host's `nightMode` capability. Live values
109
+ arrive through the host's multiplexed Signal K relay (the tile opens
110
+ no connection of its own). The iframe assets are served from a new
111
+ publicly readable `/plotterext/signalk-dead-reckoning/` route (a
112
+ minimal in-repo static handler — the plugin stays zero-dependency);
113
+ discovery is the authenticated resources API, enablement is the
114
+ plugin's own enable switch. The bus client
115
+ (`signalk-plotterext-bus` 0.11.0, MIT) is vendored under
116
+ `public/vendor/plotterext-bus/` like Leaflet. The standalone webapp
117
+ is unaffected.
118
+
8
119
  ## [0.3.0] - 2026-08-27
9
120
 
10
121
  ### Added
package/README.md CHANGED
@@ -3,3 +3,39 @@
3
3
  An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, and vertical angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to `signalk-logbook`.
4
4
 
5
5
  **Note:** This is just a toy. Make your own navigation calculations and decisions.
6
+
7
+ ## Requirements
8
+
9
+ * This plugin installed and configured
10
+ * Some chart source available to Signal K
11
+ * Hand bearing compass and/or sextant
12
+
13
+ ## Getting started
14
+
15
+ 1. Install the plugin from the Signal K Appstore (or `npm install @meri-imperiumi/signalk-dead-reckoning`) and enable it. The defaults are sane — you only need to revisit the configuration if you want to change intervals or integrations.
16
+ 2. Make sure the required data feeds are available on the vessel network:
17
+ * `navigation.position` (GPS) — the training baseline and anomaly reference
18
+ * `navigation.speedThroughWater` (paddlewheel log) and `navigation.headingMagnetic` (compass + variation) — the water-track inputs
19
+ * Wind data — used by the calibration model and the polar speed fallback
20
+ 3. Just sail. While GPS is reliable and you're under sail (propulsion stopped), the engine continuously learns vessel-specific leeway and speed-loss corrections against GPS ground truth. There is nothing to activate: the "shadow boat" runs at all times, and the uncertainty polygon tightens over a season as the calibration bins fill up. Early on, expect conservative uncertainty estimates.
21
+ 4. Optional integrations, configured in the plugin settings:
22
+ * `signalk-logbook` — write confirmed fixes and maneuvers to the vessel's logbook
23
+ * `signalk-polar-performance-plugin` — polar-derived speed when the paddlewheel is fouled or silent
24
+ * `@meri-imperiumi/signalk-autostate` — keeps the navigation state (anchored, sailing, motoring) accurate, which gates training
25
+ * Signal K Weather API — point-forecast current vectors for the DR solution
26
+
27
+ ## Daily use
28
+
29
+ Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel and the shadow boat on the chart, GPS↔DR divergence, the uncertainty polygon, and the water-track log. A status tile is also available for Freeboard-SK ≥ 3.0.
30
+
31
+ **Entering fixes.** Open *⊕ Sight / LOP* and pick the method:
32
+
33
+ * **Bearing** — compass bearing to a known object (lighthouse, tower) gives a line of position
34
+ * **Vert. Angle** — vertical angle to a known object of known height gives a circular position line
35
+ * **Celestial** — sextant sight of the Sun, Moon, or an almanac star
36
+
37
+ You can right-click an object on the map to pre-fill its coordinates. Sights can be entered with a stopwatch delay ("minutes ago") instead of clock time. Observations accumulate in the pending list; select two or more, *Preview selected* to resolve the candidate fix, then *Confirm fix* to snap dead reckoning to it. A single line of position can also be advanced to a later sight (running fix).
38
+
39
+ **When GPS looks wrong.** The plugin raises notifications for GPS anomalies, growing DR divergence, and paddlewheel fouling — but it never switches navigational authority by itself. If you decide to trust DR over GPS, press *Engage OVERRIDE*; the shadow boat takes over `navigation.position` instantly (it has been running all along). Release it when position is re-established, e.g. after confirming a fix.
40
+
41
+ **Other controls.** *≋ Current* sets a manual set-and-drift override with a TTL, which outranks the automatic current sources while it lasts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/index.js CHANGED
@@ -79,6 +79,12 @@ const {
79
79
  const { resolveCandidateFix, confirmFix } = require("./fix-pipeline.js");
80
80
  const { reduceSight, reduceNoonSight } = require("./celestial.js");
81
81
  const starAlmanac = require("./star-almanac.js");
82
+ const { registerPlotterExtension } = require("./plotterext.js");
83
+ const { registerStatusTileExamples } = require("./statustilesexamples.js");
84
+ const {
85
+ createShadowVesselPublisher,
86
+ resolveVelocity: resolveShadowVelocity,
87
+ } = require("./shadow-vessel.js");
82
88
  const { computeRadius } = require("./uncertainty.js");
83
89
  const {
84
90
  DEFAULT_FACTOR,
@@ -297,6 +303,18 @@ const DEFAULT_CONFIG = {
297
303
  baseUrl: "", // empty → http://localhost:<server port>
298
304
  intervalMs: 1800000,
299
305
  },
306
+ /**
307
+ * Shadow vessel (work doc #21): publish the DR position as a synthetic
308
+ * `vessels.<id>` target so chart plotters (Freeboard-SK's AIS layer)
309
+ * render the shadow boat on the chart. Opt-in — off by default. The
310
+ * `buddy` flag is hardcoded on (it's the distinctness mechanism, not a
311
+ * preference): Freeboard-SK's only per-target data-driven visual lever
312
+ * is the buddy flag → the green ais_buddy glyph.
313
+ */
314
+ shadowVessel: {
315
+ enabled: false,
316
+ name: "DR Shadow",
317
+ },
300
318
  };
301
319
 
302
320
  /**
@@ -357,6 +375,8 @@ const deps = {
357
375
  createPolarSpeedState,
358
376
  polarSpeedSample,
359
377
  polarSpeedAverage,
378
+ createShadowVesselPublisher,
379
+ resolveShadowVelocity,
360
380
  distanceNm,
361
381
  bearingDeg,
362
382
  };
@@ -397,6 +417,24 @@ module.exports = (app) => {
397
417
  /** @type {WeatherCurrentClient|null} §6.2 tier-3 weather current poller */
398
418
  let weatherClient = null;
399
419
 
420
+ /** @type {(() => void)|null} plotter-extension provider teardown (work doc #19) */
421
+ let plotterExtTeardown = null;
422
+
423
+ /** @type {(() => void)|null} status-tiles examples provider teardown (work doc #22) */
424
+ let statusTileExamplesTeardown = null;
425
+
426
+ /** @type {{publish: Function, stop: Function}|null} shadow vessel publisher (work doc #21) */
427
+ let shadow = null;
428
+
429
+ /**
430
+ * The shadow vessel's stable `vessels.<uuid>` context (work doc #21),
431
+ * when the shadow is enabled — mirrored into GET /status so the DR
432
+ * webapp's AIS layer can filter it out (it already draws the DR marker
433
+ * itself; rendering the shadow again as "traffic" would double-draw).
434
+ * @type {string|null}
435
+ */
436
+ let shadowContext = null;
437
+
400
438
  /**
401
439
  * Manual set-and-drift override (§6.2 tier 1): watchstander input,
402
440
  * honored while its TTL lasts. Set/cleared via
@@ -585,6 +623,18 @@ module.exports = (app) => {
585
623
  title: "Polar speed staleness cutoff (s)",
586
624
  default: DEFAULT_CONFIG.polar.staleS,
587
625
  },
626
+ "shadowVessel.enabled": {
627
+ type: "boolean",
628
+ title: "Show the DR shadow boat on chart plotters",
629
+ description:
630
+ "Publishes the DR position as a synthetic vessel (a green 'buddy' target on Freeboard-SK) so chart plotters that source vessels from the Signal K stream render the shadow boat on the chart alongside your own vessel, with a projected COG line. The plugin stays the source of truth; this only adds a chart-visible target.",
631
+ default: DEFAULT_CONFIG.shadowVessel.enabled,
632
+ },
633
+ "shadowVessel.name": {
634
+ type: "string",
635
+ title: "Shadow vessel label",
636
+ default: DEFAULT_CONFIG.shadowVessel.name,
637
+ },
588
638
  },
589
639
  },
590
640
 
@@ -615,6 +665,10 @@ module.exports = (app) => {
615
665
  ...DEFAULT_CONFIG.polar,
616
666
  ...(options?.polar ?? {}),
617
667
  };
668
+ config.shadowVessel = {
669
+ ...DEFAULT_CONFIG.shadowVessel,
670
+ ...(options?.shadowVessel ?? {}),
671
+ };
618
672
 
619
673
  dbPath = join(app.getDataDirPath(), "dead-reckoning.sqlite");
620
674
  db = deps.openDatabase(dbPath);
@@ -700,6 +754,25 @@ module.exports = (app) => {
700
754
  const logSinceOrigin = deps.getState(db, "dr_log_since_origin");
701
755
  if (logSinceOrigin) engine.logNmSinceOrigin = Number(logSinceOrigin) || 0;
702
756
 
757
+ // Shadow vessel (work doc #21): publish the DR position as a synthetic
758
+ // vessels.<id> target so chart plotters render it. The context is a
759
+ // stable UUID persisted across restarts so the plotter's target id
760
+ // is continuous (no flicker/age-out on restart). Generated once and
761
+ // reused — never collides with a real AIS target.
762
+ if (config.shadowVessel.enabled) {
763
+ let shadowCtx = deps.getState(db, "shadow_vessel_context");
764
+ if (!shadowCtx) {
765
+ shadowCtx = `vessels.urn:mrn:signalk:uuid:${crypto.randomUUID()}`;
766
+ deps.setState(db, "shadow_vessel_context", shadowCtx);
767
+ }
768
+ shadowContext = shadowCtx;
769
+ shadow = deps.createShadowVesselPublisher({
770
+ app,
771
+ context: shadowCtx,
772
+ name: config.shadowVessel.name,
773
+ });
774
+ }
775
+
703
776
  initLogbook();
704
777
 
705
778
  // Subscribe to the sensor inputs the engine needs.
@@ -738,6 +811,22 @@ module.exports = (app) => {
738
811
  publishMeta();
739
812
  setStatus("Dead reckoning started");
740
813
 
814
+ // Plotter-extension host integration (work doc #19): advertise
815
+ // the status-tile manifest to chart plotters (Freeboard-SK ≥3.0)
816
+ // and serve the iframe assets at a public, non-admin-gated route.
817
+ plotterExtTeardown = registerPlotterExtension(app, {
818
+ id: PLUGIN_ID,
819
+ });
820
+
821
+ // Status Tiles example-set provider (work doc #22): advertise
822
+ // the DR position tile set so the Status Tiles webapp offers it
823
+ // as a one-tap "Add" that merges into the user's panel. Read-only,
824
+ // running-gated — returns {} when stopped so a disabled plugin
825
+ // contributes no stale sets.
826
+ statusTileExamplesTeardown = registerStatusTileExamples(app, {
827
+ id: PLUGIN_ID,
828
+ });
829
+
741
830
  // Public config endpoint (CONFIG_PATH): mounted on the app so
742
831
  // anonymous / read-only clients can read the plugin config
743
832
  // (incl. positionFormat) without admin auth. Mirrors
@@ -793,6 +882,13 @@ module.exports = (app) => {
793
882
  weatherClient?.stop();
794
883
  weatherClient = null;
795
884
  training = null;
885
+ plotterExtTeardown?.();
886
+ plotterExtTeardown = null;
887
+ statusTileExamplesTeardown?.();
888
+ statusTileExamplesTeardown = null;
889
+ shadow?.stop();
890
+ shadow = null;
891
+ shadowContext = null;
796
892
  // Hygiene: clear a live advisory so it doesn't linger after the
797
893
  // plugin stops monitoring.
798
894
  if (divergence?.active) {
@@ -1067,6 +1163,14 @@ module.exports = (app) => {
1067
1163
  [PATHS.elapsedSinceFix]: engine.elapsedSinceOriginS,
1068
1164
  [PATHS.divergenceDistance]: null,
1069
1165
  });
1166
+ // Shadow vessel (work doc #21): keep the shadow on the chart at the
1167
+ // last position so it doesn't vanish or go stale while DR is idle.
1168
+ // SOG=0 clears any previously-drawn COG line so it doesn't linger
1169
+ // stale; heading/COG are omitted (the plotter retains the last
1170
+ // values, which is honest — the shadow isn't moving).
1171
+ if (shadow && engine.origin) {
1172
+ shadow.publish({ position: engine.origin, sogMs: 0 });
1173
+ }
1070
1174
  return;
1071
1175
  }
1072
1176
 
@@ -1348,6 +1452,29 @@ module.exports = (app) => {
1348
1452
  ? dvg.distance_nm * METRES_PER_NM
1349
1453
  : null,
1350
1454
  });
1455
+
1456
+ // Shadow vessel (work doc #21): emit the DR position + a derived
1457
+ // COG/SOG so the plotter draws the shadow with a projected COG
1458
+ // line. The velocity vector mirrors the engine's motion model
1459
+ // (water-track + current) so the line projects consistently. Units
1460
+ // are Signal K SI (rad, m/s), matching how the plugin publishes
1461
+ // the own-vessel current vector above.
1462
+ if (shadow && pos) {
1463
+ const vel = deps.resolveShadowVelocity({
1464
+ stwKn: effectiveStwKn,
1465
+ headingTrueDeg,
1466
+ leewayDeg: corrections.leeway_angle,
1467
+ speedLoss: corrections.speed_loss,
1468
+ current,
1469
+ });
1470
+ shadow.publish({
1471
+ position: pos,
1472
+ headingTrueRad:
1473
+ headingTrueDeg != null ? degToRad(headingTrueDeg) : null,
1474
+ cogRad: vel ? degToRad(vel.cogDeg) : null,
1475
+ sogMs: vel ? knotsToMs(vel.sogKn) : null,
1476
+ });
1477
+ }
1351
1478
  }
1352
1479
  }
1353
1480
 
@@ -1900,6 +2027,10 @@ module.exports = (app) => {
1900
2027
  // the UI's header readout can bootstrap without a delta.
1901
2028
  current: lastCurrent,
1902
2029
  manualCurrent,
2030
+ // Work doc #23: the shadow vessel's context, when enabled, so
2031
+ // the DR webapp can filter it from its AIS target layer (the
2032
+ // webapp already renders the DR position as its own marker).
2033
+ shadowVesselContext: shadowContext,
1903
2034
  });
1904
2035
  });
1905
2036
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Plotter-extension host integration (Freeboard-SK and any Plotter
3
+ * Extensions API v1 chart plotter).
4
+ *
5
+ * Two server-side responsibilities (see freeboard-sk
6
+ * docs/api/plotter_extension_provider_plugins.md):
7
+ *
8
+ * 1. Register a read-only `plotterExtensions` resource provider whose
9
+ * single entry is this plugin's manifest — the host discovers the
10
+ * extension there. Presence in the collection *is* the enablement
11
+ * signal: hosts must not add a second per-extension gate, and the
12
+ * provider goes empty on plugin stop so the host tears the contexts
13
+ * down.
14
+ * 2. Serve the extension's iframe assets at a publicly readable,
15
+ * non-admin-gated route. `/plugins/*` is admin-only on the server, so
16
+ * the manifest URLs point at a self-mounted namespaced prefix instead.
17
+ * The assets are inert UI code — all data flows over the host bus.
18
+ * Served by a minimal in-repo static handler rather than
19
+ * `express.static`: the plugin is zero-dependency by design (SPEC §2)
20
+ * and `express` is not resolvable from a plugin's own tree — only
21
+ * from inside the server. The handler only needs to serve the small,
22
+ * known-good set of files under `public/`.
23
+ *
24
+ * @file plotterext.js
25
+ */
26
+
27
+ const path = require("node:path");
28
+ const fs = require("node:fs");
29
+ const pkg = require("../package.json");
30
+
31
+ /**
32
+ * Directory holding the extension's iframe assets. The same `public/`
33
+ * the standalone webapp is served from (the webapp keyword mounts it at
34
+ * the package path); the extension entry pages live alongside the ES
35
+ * modules they import, so relative imports keep working under either
36
+ * mount.
37
+ */
38
+ const PUBLIC_DIR = path.join(__dirname, "..", "public");
39
+
40
+ /** Content types for the extension's asset kinds (inert UI code). */
41
+ const MIME = {
42
+ ".html": "text/html; charset=utf-8",
43
+ ".js": "text/javascript; charset=utf-8",
44
+ ".mjs": "text/javascript; charset=utf-8",
45
+ ".css": "text/css; charset=utf-8",
46
+ ".png": "image/png",
47
+ ".svg": "image/svg+xml",
48
+ ".ico": "image/x-icon",
49
+ ".json": "application/json; charset=utf-8",
50
+ ".txt": "text/plain; charset=utf-8",
51
+ };
52
+
53
+ /**
54
+ * Builds the extension manifest.
55
+ *
56
+ * `requires` lists only what the tile genuinely cannot run without: the
57
+ * widget grid and the Signal K relay (a status tile with no data is dead
58
+ * weight). Night mode is used when present and guarded with
59
+ * `hasCapability` at runtime.
60
+ *
61
+ * @param {string} assetBase - server-relative URL prefix for assets
62
+ * @param {string} version - plugin version for display metadata
63
+ * @returns {object} manifest per the Plotter Extensions API v1
64
+ */
65
+ function buildManifest(assetBase, version) {
66
+ return {
67
+ name: "Dead Reckoning",
68
+ description:
69
+ "Dead reckoning status tile: GPS↔DR divergence, uncertainty radius and DR health at a glance.",
70
+ version,
71
+ apiVersion: "1",
72
+ requires: ["widgets", "signalk.stream"],
73
+ optional: ["nightMode"],
74
+ widgets: [
75
+ {
76
+ id: "dr-status",
77
+ title: "Dead Reckoning",
78
+ type: "iframe",
79
+ url: `${assetBase}/dr-ext-widget.html`,
80
+ size: "2x1",
81
+ lifecycle: "whileEnabled",
82
+ },
83
+ ],
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Minimal static-asset middleware for the extension's files. Not a
89
+ * general file server: only regular files directly under `root` are
90
+ * served (directory indexes and anything escaping `root` fall through).
91
+ *
92
+ * @param {string} root - absolute directory to serve
93
+ * @param {string} assetBase - mount prefix, stripped when the host did
94
+ * not already strip it (Express strips it for `app.use(path, fn)`;
95
+ * other hosts may pass the full path)
96
+ * @returns {(req: object, res: object, next: Function) => void}
97
+ */
98
+ function staticAssetHandler(root, assetBase) {
99
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
100
+ return (req, res, next) => {
101
+ let urlPath = req.path ?? req.url ?? "/";
102
+ const q = urlPath.indexOf("?");
103
+ if (q >= 0) urlPath = urlPath.slice(0, q);
104
+ if (urlPath.startsWith(assetBase)) {
105
+ urlPath = urlPath.slice(assetBase.length);
106
+ }
107
+ if (!urlPath.startsWith("/")) urlPath = `/${urlPath}`;
108
+
109
+ let resolved;
110
+ try {
111
+ resolved = path.normalize(
112
+ path.join(rootPrefix, decodeURIComponent(urlPath)),
113
+ );
114
+ } catch {
115
+ next(); // malformed percent-encoding
116
+ return;
117
+ }
118
+ // Traversal guard: the resolved path must stay inside root.
119
+ if (!resolved.startsWith(rootPrefix)) {
120
+ next();
121
+ return;
122
+ }
123
+
124
+ fs.stat(resolved, (err, st) => {
125
+ if (err || !st.isFile()) {
126
+ next();
127
+ return;
128
+ }
129
+ res.statusCode = 200;
130
+ res.setHeader(
131
+ "Content-Type",
132
+ MIME[path.extname(resolved)] ?? "application/octet-stream",
133
+ );
134
+ res.setHeader("Cache-Control", "public, max-age=3600");
135
+ fs.readFile(resolved, (readErr, data) => {
136
+ if (readErr) {
137
+ next();
138
+ return;
139
+ }
140
+ res.end(data);
141
+ });
142
+ });
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Registers the plotter-extension provider and mounts the asset route.
148
+ *
149
+ * @param {import("@signalk/server-api").ServerAPI} app - Signal K server API
150
+ * @param {{id: string, version?: string}} opts - plugin id (manifest key)
151
+ * and optional version override (defaults to the package version)
152
+ * @returns {() => void} teardown — empties the provider's listing so
153
+ * hosts unload the extension's contexts. The static asset mount stays
154
+ * (Express has no unmount, and the files are inert); that matches the
155
+ * webapp keyword's behaviour for a disabled plugin.
156
+ */
157
+ function registerPlotterExtension(app, opts) {
158
+ const { id } = opts;
159
+ const version = opts.version ?? pkg.version;
160
+ const assetBase = `/plotterext/${id}`;
161
+ const manifest = buildManifest(assetBase, version);
162
+ let running = true;
163
+
164
+ app.registerResourceProvider({
165
+ type: "plotterExtensions",
166
+ methods: {
167
+ listResources: async () => (running ? { [id]: manifest } : {}),
168
+ getResource: async (resourceId) => {
169
+ if (!running || resourceId !== id) {
170
+ throw new Error(`No such plotterExtensions resource: ${resourceId}`);
171
+ }
172
+ return manifest;
173
+ },
174
+ setResource: async () => {
175
+ throw new Error(`${id} is a read-only provider`);
176
+ },
177
+ deleteResource: async () => {
178
+ throw new Error(`${id} is a read-only provider`);
179
+ },
180
+ },
181
+ });
182
+
183
+ app.use(assetBase, staticAssetHandler(PUBLIC_DIR, assetBase));
184
+
185
+ return () => {
186
+ running = false;
187
+ };
188
+ }
189
+
190
+ module.exports = {
191
+ buildManifest,
192
+ registerPlotterExtension,
193
+ staticAssetHandler,
194
+ };