@meri-imperiumi/signalk-dead-reckoning 0.3.0 → 0.4.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.
- package/CHANGELOG.md +87 -0
- package/README.md +36 -0
- package/package.json +1 -1
- package/plugin/index.js +116 -0
- package/plugin/plotterext.js +194 -0
- package/plugin/shadow-vessel.js +179 -0
- package/plugin/statustilesexamples.js +109 -0
- package/public/dr-app.js +4 -3
- package/public/dr-ext-model.js +84 -0
- package/public/dr-ext-widget.html +29 -0
- package/public/dr-ext-widget.js +275 -0
- package/public/dr-map-view.js +239 -26
- package/public/dr-sight-panel.js +11 -3
- package/public/dr-viewmodel.js +370 -2
- package/public/index.html +5 -0
- package/public/vendor/maplibre-gl/LICENSE-leaflet-maplibre-gl.txt +15 -0
- package/public/vendor/maplibre-gl/LICENSE-maplibre-gl.txt +116 -0
- package/public/vendor/maplibre-gl/README.md +19 -0
- package/public/vendor/maplibre-gl/leaflet-maplibre-gl.js +234 -0
- package/public/vendor/maplibre-gl/maplibre-gl.css +1 -0
- package/public/vendor/maplibre-gl/maplibre-gl.js +59 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +12 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +318 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +267 -0
- package/public/vendor/plotterext-bus/extension.js +31 -0
- package/status-tiles-examples.json +120 -0
- package/tests/dr-ext-model.test.js +96 -0
- package/tests/fake-app.js +21 -0
- package/tests/plotterext.test.js +188 -0
- package/tests/shadow-vessel-integration.test.js +201 -0
- package/tests/shadow-vessel.test.js +209 -0
- package/tests/statustilesexamples.test.js +174 -0
- package/tests/vector-charts.test.js +516 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ 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.4.0] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Example Status Tiles set (work doc #22)** — the plugin now ships a
|
|
12
|
+
ready-made Status Tiles set (`status-tiles-examples.json`) that a
|
|
13
|
+
boat owner copies into their panel with one tap — no JSON editing,
|
|
14
|
+
no hand-authoring predicates. The set exposes the underway DR
|
|
15
|
+
integrity tile: the sensor-health and divergence-advisory
|
|
16
|
+
notifications, the DR-override (`navigation.deadReckoning.active`)
|
|
17
|
+
state, and banded checks on the uncertainty radius (1 nm warn / 3 nm
|
|
18
|
+
crit) and time-since-fix (3 h / 6 h), with footer readouts for
|
|
19
|
+
uncertainty, DR–GPS divergence, and the DR method. It is advertised
|
|
20
|
+
through the standard resources API as a read-only
|
|
21
|
+
`statusTileExamples` provider (keyed by the plugin id, returning `{}`
|
|
22
|
+
when stopped so a disabled plugin contributes no stale sets),
|
|
23
|
+
which the signalk-status-tiles webapp discovers and offers as a
|
|
24
|
+
one-tap "Add" that merges into the user's config (skipping existing
|
|
25
|
+
ids — idempotent, never overwrites user edits). Mirrors the existing
|
|
26
|
+
plotter-extension provider pattern; no running server is required to
|
|
27
|
+
ship or consume it.
|
|
28
|
+
- **Shadow vessel on chart plotters (work doc #21)** — the DR
|
|
29
|
+
shadow-boat position is now published as a synthetic vessel on a
|
|
30
|
+
stable `vessels.<uuid>` context, so chart plotters that source
|
|
31
|
+
vessels from the Signal K stream (Freeboard-SK's AIS layer) render
|
|
32
|
+
it on the chart alongside the own vessel and AIS traffic, with a
|
|
33
|
+
projected COG line. Freeboard-SK draws it as a green `ais_buddy`
|
|
34
|
+
target — the only per-target data-driven visual lever in its AIS
|
|
35
|
+
style decision tree is the `buddy` flag, which the plugin sets on the
|
|
36
|
+
root-value delta. The gap between the shadow's heading (bow) and its
|
|
37
|
+
COG line visualizes set + leeway, mirroring how the plotter treats
|
|
38
|
+
the own vessel. Opt-in via *Shadow Vessel* in the plugin settings
|
|
39
|
+
(off by default); the configurable label defaults to "DR Shadow".
|
|
40
|
+
The plugin stays the source of truth — the shadow is an additional
|
|
41
|
+
chart-visible target, not a navigational authority. The context UUID
|
|
42
|
+
is generated once and persisted so the plotter's target id is
|
|
43
|
+
continuous across restarts. While DR is idle (moored, no speed or
|
|
44
|
+
heading) the shadow keeps publishing the last position at SOG 0 so
|
|
45
|
+
it doesn't vanish or go stale, and the COG line clears. No
|
|
46
|
+
Freeboard-SK change is required.
|
|
47
|
+
- **Vector chart tiles in the webapp map (work doc #20)** — charts
|
|
48
|
+
with `format: 'pbf'` in the server's configured charts resource
|
|
49
|
+
(NOAA ENC-derived S-57 MBTiles, an Open Waters passage cache, any
|
|
50
|
+
vector MBTiles served by `signalk-charts-provider-simple`) now
|
|
51
|
+
render instead of failing silently as broken image tiles. MapLibre
|
|
52
|
+
GL JS 5.24.0 and the official Leaflet bridge are vendored under
|
|
53
|
+
`public/vendor/maplibre-gl/` (same policy as Leaflet — no CDN, no
|
|
54
|
+
build step) and mounted via `L.maplibreGL`, so all DR overlays stay
|
|
55
|
+
plain Leaflet. When the corridor downloader's asset manifest
|
|
56
|
+
(`/plugins/signalk-corridor-tile-downloader/assets/manifest.json`)
|
|
57
|
+
advertises a mirrored Open Waters `style` URL, that style is mounted
|
|
58
|
+
wholesale — full marine symbology (buoys by shape and colour,
|
|
59
|
+
lights, restricted-area hatching), bathymetry contours and soundings
|
|
60
|
+
with labels, base map and hillshade, online and offline alike —
|
|
61
|
+
while terrarium-DEM (`webp`) stores stay mirror internals and never
|
|
62
|
+
appear as overlays. For sources without a manifest the style is
|
|
63
|
+
composed client-side by the pure `maplibreStyleFor` (view-model):
|
|
64
|
+
one vector source on the chart's absolute tile URL (MapLibre's
|
|
65
|
+
blob-URL workers can't resolve relative paths) with native max zoom
|
|
66
|
+
— vector charts keep rendering past the raster maxNativeZoom
|
|
67
|
+
ceiling — plus a dark sea background and geometry-only
|
|
68
|
+
fill/line/circle layers with marine styling for known source-layer
|
|
69
|
+
families (S-57 LNDARE/DEPARE/DEPCNT/COALNE/SOUNDG ids and the
|
|
70
|
+
OSM-marine names the corridor cache carries: seamark, waterway,
|
|
71
|
+
wetland, sea_area, light as amber dots). Chart-mount failures are
|
|
72
|
+
logged instead of vanishing. Right-clicking a charted symbol
|
|
73
|
+
(lighthouse, seamark, peak, island) resolves its charted name and
|
|
74
|
+
seeds the sight form's *Object* field — bearings are taken to
|
|
75
|
+
identified objects, not bare coordinates.
|
|
76
|
+
- **Plotter-extension status tile for Freeboard-SK (work doc #19)** —
|
|
77
|
+
the plugin now registers a `plotterExtensions` resource provider
|
|
78
|
+
(Plotter Extensions API v1) so chart plotters that host the
|
|
79
|
+
mechanism (Freeboard-SK ≥ 3.0) offer a "Dead Reckoning" widget:
|
|
80
|
+
a 2×1 glanceable tile showing GPS↔DR divergence, the DR uncertainty
|
|
81
|
+
radius, fix cadence and DR health (tracking / maneuver / fouled /
|
|
82
|
+
stale / warm), colored by severity like the webapp status line, with
|
|
83
|
+
an OVERRIDE badge when DR is authoritative and an amber night-mode
|
|
84
|
+
palette following the host's `nightMode` capability. Live values
|
|
85
|
+
arrive through the host's multiplexed Signal K relay (the tile opens
|
|
86
|
+
no connection of its own). The iframe assets are served from a new
|
|
87
|
+
publicly readable `/plotterext/signalk-dead-reckoning/` route (a
|
|
88
|
+
minimal in-repo static handler — the plugin stays zero-dependency);
|
|
89
|
+
discovery is the authenticated resources API, enablement is the
|
|
90
|
+
plugin's own enable switch. The bus client
|
|
91
|
+
(`signalk-plotterext-bus` 0.11.0, MIT) is vendored under
|
|
92
|
+
`public/vendor/plotterext-bus/` like Leaflet. The standalone webapp
|
|
93
|
+
is unaffected.
|
|
94
|
+
|
|
8
95
|
## [0.3.0] - 2026-08-27
|
|
9
96
|
|
|
10
97
|
### 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
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,15 @@ 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
|
+
|
|
400
429
|
/**
|
|
401
430
|
* Manual set-and-drift override (§6.2 tier 1): watchstander input,
|
|
402
431
|
* honored while its TTL lasts. Set/cleared via
|
|
@@ -585,6 +614,18 @@ module.exports = (app) => {
|
|
|
585
614
|
title: "Polar speed staleness cutoff (s)",
|
|
586
615
|
default: DEFAULT_CONFIG.polar.staleS,
|
|
587
616
|
},
|
|
617
|
+
"shadowVessel.enabled": {
|
|
618
|
+
type: "boolean",
|
|
619
|
+
title: "Show the DR shadow boat on chart plotters",
|
|
620
|
+
description:
|
|
621
|
+
"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.",
|
|
622
|
+
default: DEFAULT_CONFIG.shadowVessel.enabled,
|
|
623
|
+
},
|
|
624
|
+
"shadowVessel.name": {
|
|
625
|
+
type: "string",
|
|
626
|
+
title: "Shadow vessel label",
|
|
627
|
+
default: DEFAULT_CONFIG.shadowVessel.name,
|
|
628
|
+
},
|
|
588
629
|
},
|
|
589
630
|
},
|
|
590
631
|
|
|
@@ -615,6 +656,10 @@ module.exports = (app) => {
|
|
|
615
656
|
...DEFAULT_CONFIG.polar,
|
|
616
657
|
...(options?.polar ?? {}),
|
|
617
658
|
};
|
|
659
|
+
config.shadowVessel = {
|
|
660
|
+
...DEFAULT_CONFIG.shadowVessel,
|
|
661
|
+
...(options?.shadowVessel ?? {}),
|
|
662
|
+
};
|
|
618
663
|
|
|
619
664
|
dbPath = join(app.getDataDirPath(), "dead-reckoning.sqlite");
|
|
620
665
|
db = deps.openDatabase(dbPath);
|
|
@@ -700,6 +745,24 @@ module.exports = (app) => {
|
|
|
700
745
|
const logSinceOrigin = deps.getState(db, "dr_log_since_origin");
|
|
701
746
|
if (logSinceOrigin) engine.logNmSinceOrigin = Number(logSinceOrigin) || 0;
|
|
702
747
|
|
|
748
|
+
// Shadow vessel (work doc #21): publish the DR position as a synthetic
|
|
749
|
+
// vessels.<id> target so chart plotters render it. The context is a
|
|
750
|
+
// stable UUID persisted across restarts so the plotter's target id
|
|
751
|
+
// is continuous (no flicker/age-out on restart). Generated once and
|
|
752
|
+
// reused — never collides with a real AIS target.
|
|
753
|
+
if (config.shadowVessel.enabled) {
|
|
754
|
+
let shadowCtx = deps.getState(db, "shadow_vessel_context");
|
|
755
|
+
if (!shadowCtx) {
|
|
756
|
+
shadowCtx = `vessels.urn:mrn:signalk:uuid:${crypto.randomUUID()}`;
|
|
757
|
+
deps.setState(db, "shadow_vessel_context", shadowCtx);
|
|
758
|
+
}
|
|
759
|
+
shadow = deps.createShadowVesselPublisher({
|
|
760
|
+
app,
|
|
761
|
+
context: shadowCtx,
|
|
762
|
+
name: config.shadowVessel.name,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
703
766
|
initLogbook();
|
|
704
767
|
|
|
705
768
|
// Subscribe to the sensor inputs the engine needs.
|
|
@@ -738,6 +801,22 @@ module.exports = (app) => {
|
|
|
738
801
|
publishMeta();
|
|
739
802
|
setStatus("Dead reckoning started");
|
|
740
803
|
|
|
804
|
+
// Plotter-extension host integration (work doc #19): advertise
|
|
805
|
+
// the status-tile manifest to chart plotters (Freeboard-SK ≥3.0)
|
|
806
|
+
// and serve the iframe assets at a public, non-admin-gated route.
|
|
807
|
+
plotterExtTeardown = registerPlotterExtension(app, {
|
|
808
|
+
id: PLUGIN_ID,
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// Status Tiles example-set provider (work doc #22): advertise
|
|
812
|
+
// the DR position tile set so the Status Tiles webapp offers it
|
|
813
|
+
// as a one-tap "Add" that merges into the user's panel. Read-only,
|
|
814
|
+
// running-gated — returns {} when stopped so a disabled plugin
|
|
815
|
+
// contributes no stale sets.
|
|
816
|
+
statusTileExamplesTeardown = registerStatusTileExamples(app, {
|
|
817
|
+
id: PLUGIN_ID,
|
|
818
|
+
});
|
|
819
|
+
|
|
741
820
|
// Public config endpoint (CONFIG_PATH): mounted on the app so
|
|
742
821
|
// anonymous / read-only clients can read the plugin config
|
|
743
822
|
// (incl. positionFormat) without admin auth. Mirrors
|
|
@@ -793,6 +872,12 @@ module.exports = (app) => {
|
|
|
793
872
|
weatherClient?.stop();
|
|
794
873
|
weatherClient = null;
|
|
795
874
|
training = null;
|
|
875
|
+
plotterExtTeardown?.();
|
|
876
|
+
plotterExtTeardown = null;
|
|
877
|
+
statusTileExamplesTeardown?.();
|
|
878
|
+
statusTileExamplesTeardown = null;
|
|
879
|
+
shadow?.stop();
|
|
880
|
+
shadow = null;
|
|
796
881
|
// Hygiene: clear a live advisory so it doesn't linger after the
|
|
797
882
|
// plugin stops monitoring.
|
|
798
883
|
if (divergence?.active) {
|
|
@@ -1067,6 +1152,14 @@ module.exports = (app) => {
|
|
|
1067
1152
|
[PATHS.elapsedSinceFix]: engine.elapsedSinceOriginS,
|
|
1068
1153
|
[PATHS.divergenceDistance]: null,
|
|
1069
1154
|
});
|
|
1155
|
+
// Shadow vessel (work doc #21): keep the shadow on the chart at the
|
|
1156
|
+
// last position so it doesn't vanish or go stale while DR is idle.
|
|
1157
|
+
// SOG=0 clears any previously-drawn COG line so it doesn't linger
|
|
1158
|
+
// stale; heading/COG are omitted (the plotter retains the last
|
|
1159
|
+
// values, which is honest — the shadow isn't moving).
|
|
1160
|
+
if (shadow && engine.origin) {
|
|
1161
|
+
shadow.publish({ position: engine.origin, sogMs: 0 });
|
|
1162
|
+
}
|
|
1070
1163
|
return;
|
|
1071
1164
|
}
|
|
1072
1165
|
|
|
@@ -1348,6 +1441,29 @@ module.exports = (app) => {
|
|
|
1348
1441
|
? dvg.distance_nm * METRES_PER_NM
|
|
1349
1442
|
: null,
|
|
1350
1443
|
});
|
|
1444
|
+
|
|
1445
|
+
// Shadow vessel (work doc #21): emit the DR position + a derived
|
|
1446
|
+
// COG/SOG so the plotter draws the shadow with a projected COG
|
|
1447
|
+
// line. The velocity vector mirrors the engine's motion model
|
|
1448
|
+
// (water-track + current) so the line projects consistently. Units
|
|
1449
|
+
// are Signal K SI (rad, m/s), matching how the plugin publishes
|
|
1450
|
+
// the own-vessel current vector above.
|
|
1451
|
+
if (shadow && pos) {
|
|
1452
|
+
const vel = deps.resolveShadowVelocity({
|
|
1453
|
+
stwKn: effectiveStwKn,
|
|
1454
|
+
headingTrueDeg,
|
|
1455
|
+
leewayDeg: corrections.leeway_angle,
|
|
1456
|
+
speedLoss: corrections.speed_loss,
|
|
1457
|
+
current,
|
|
1458
|
+
});
|
|
1459
|
+
shadow.publish({
|
|
1460
|
+
position: pos,
|
|
1461
|
+
headingTrueRad:
|
|
1462
|
+
headingTrueDeg != null ? degToRad(headingTrueDeg) : null,
|
|
1463
|
+
cogRad: vel ? degToRad(vel.cogDeg) : null,
|
|
1464
|
+
sogMs: vel ? knotsToMs(vel.sogKn) : null,
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1351
1467
|
}
|
|
1352
1468
|
}
|
|
1353
1469
|
|
|
@@ -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
|
+
};
|