@meri-imperiumi/signalk-dead-reckoning 0.11.2 → 0.12.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 CHANGED
@@ -7,6 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-09-21
11
+
12
+ ### Added
13
+ - **Chartplotter UX (work doc #30, north-up phase):** the DR webapp
14
+ navigates like a plotter, not a map viewer:
15
+ - **Own-ship boat glyphs** replace the GPS dot and the bare DR X —
16
+ a pointed-hull vessel shape rotated to COG (GPS) and to the DR
17
+ course (DR), the navigator's X kept as a small detail inside the
18
+ DR hull. Dot/X fallbacks when course is unknown.
19
+ - **10-minute predictor vectors** (layers control, "Vectors"): a
20
+ dashed COG×SOG line from the GPS position and a DR-course×DR-speed
21
+ line from the DR position, with tick marks at 2-minute intervals;
22
+ the AIS velocity leader extends from the 6-minute to the same
23
+ 10-minute convention so every predictor on the chart reads as one
24
+ family.
25
+ - **Nautical scale bar** (bottom-left): a custom control snapped to
26
+ a 0.1–1000 NM ladder (80–200 px band), metric fallback at deep
27
+ zooms; no `1:x` numeric readout.
28
+ - **Range rings** (layers control, "Range rings"): three
29
+ concentric rings centered on the GPS position, spacing derived
30
+ from the zoom ladder, re-spaced on zoom.
31
+ - **Wind laylines** (layers control, "Laylines"): two rays from the
32
+ DR vessel at TWD ± beat/gybe angle (the polar performance plugin's
33
+ angles — the established feed), port tack red / starboard green
34
+ per the navigation-light convention, fixed screen-relative length.
35
+ Gated honestly: renders only while `navigation.state` is
36
+ `sailing`, and only when true wind and the angle this point of
37
+ sail needs are actually published.
38
+ - **Pick menu is now the target surface:** every pick shows bearing
39
+ & distance from BOTH own-ship references (DR and GPS — the
40
+ most-wanted helm readout, absent-source rows hidden); AIS picks
41
+ add the target details panel (type, flag, dimensions, destination
42
+ & ETA — Freeboard-SK's field set, new static paths in the AIS
43
+ subscription folded into the store) plus **CPA/TCPA for both
44
+ references** (DR-based = conservative, GPS-based = conventional);
45
+ a target closing inside the CPA watch limits (0.5 nm / 20 min)
46
+ turns its glyph red and carries the CPA figure in its tooltip.
47
+ - **Measure tool** ("Measure from here…" in the pick menu): tap
48
+ points, get true bearing + distance leg by leg with a running
49
+ total in a floating readout; double-click / right-click / Esc ends.
50
+ - **Signal K notes on the chart**: positioned notes from the v2
51
+ resources API render as orange pin markers on a toggleable
52
+ "Notes" layer; clicking/right-clicking opens the detail surface
53
+ (title, body rendered per mimeType with a minimal safe markdown
54
+ renderer, timestamp, DR/GPS bearings, all pick actions). The pick
55
+ menu gains "New note at…" — a one-handed hazard-marking form
56
+ (Hazard preset, position pre-seeded) that POSTs to the v2
57
+ resources API, with edit/delete from the note's menu and refetch
58
+ on stream reconnect. Failed writes surface in the form, never
59
+ silently.
60
+
61
+ - **Fixes over a day old carry their date on the chart** —
62
+ "Fix 18.9. 02:36Z" instead of the ambiguous "Fix 02:36Z". With the
63
+ new 7-day history window most fixes on the chart aren't from
64
+ today, so the time alone no longer identifies the fix. Under 24 h
65
+ the label is unchanged.
66
+ - **History-aware chart window: tracks, overlays and trip
67
+ boundaries.** The webapp now shows the last
68
+ `max(7 days, since trip start)` on the chart instead of nothing:
69
+ both the GPS track and the DR ghost track backfill from the Signal K
70
+ History API over the window (10-minute resolution — ~1000 points per
71
+ track on a full week), and the persisted chartwork overlays (fixes,
72
+ LOPs, CPLs, snap vectors) are window-filtered via a new `since`
73
+ query parameter on the REST endpoints (`/fixes`, `/observations`,
74
+ `/corrections`; ISO-8601 or epoch ms, invalid values ignored).
75
+ Two bugs fixed on the way: the DR track backfill silently never
76
+ worked — the history provider emits plugin-published positions as
77
+ `{latitude, longitude}` objects where the parser only accepted
78
+ GeoJSON `[lon, lat]` pairs — and the 6-hour fetch window was
79
+ replaced by the proper window.
80
+ - **Trip log now actually resets at trip boundaries (SPEC §9.2).**
81
+ `engine.resetTrip()` existed but nothing ever called it, so
82
+ `navigation.deadReckoning.trip.log` accumulated since install (the
83
+ production log read 703 nm — more than the last two trips
84
+ combined). A sustained `navigation.state` transition from
85
+ `anchored`/`moored` to underway (5-minute debounce, configurable
86
+ via `tripBoundary.sustainS`/`clearS`) now zeroes the trip log and
87
+ records the trip start, which is persisted across restarts
88
+ (mid-trip server restarts keep the boundary) and exposed via
89
+ GET /status as `tripStartMs` — the webapp uses it to anchor its
90
+ history window. A flapping autostate source can't zero the log:
91
+ blips shorter than the debounce are ignored.
92
+ - **The headline log figure is now the trip log.** The bottom-right
93
+ readout shows `navigation.deadReckoning.trip.log` ("Trip log")
94
+ instead of the cumulative water-track log — the watchkeeper's
95
+ glance figure is distance-since-departure; the cumulative total
96
+ rides along as the figure's hover tooltip. Falls back to the
97
+ cumulative log until a first trip boundary is ever observed.
98
+
99
+ ### Fixed
100
+ - **Hosted plotter widgets showed as empty black squares with no
101
+ data** (sea trial 2026-09-21). Two independent bugs, both in the
102
+ widget host, both reproducible with a locally served webapp against
103
+ a stub Signal K server:
104
+ - The bus port captured the widget iframe's `contentWindow` at
105
+ context creation — but a browser replaces that Window object when
106
+ the frame navigates from `about:blank` to its `src` (and the
107
+ frame is still detached, window `null`, when the context is
108
+ created). Every message from the loaded widget was silently
109
+ dropped: the handshake never completed and the tile sat on its
110
+ placeholder grid. The port now resolves the live window per
111
+ message (`liveWindowPort`).
112
+ - A placement restored from layout storage (page reload) rendered
113
+ an empty dark cell forever: the area element renders its cells
114
+ before discovery has created the widget contexts, and the iframe
115
+ was only adopted at cell creation. Adoption is now idempotent —
116
+ every render adopts a context's iframe into its cell unless it's
117
+ already home (never re-parenting a live iframe, which would
118
+ reload it).
119
+
10
120
  ## [0.11.2] - 2026-09-21
11
121
 
12
122
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.11.2",
3
+ "version": "0.12.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/db.js CHANGED
@@ -654,10 +654,14 @@ function dequeueLogbookPending(db, pendingId) {
654
654
 
655
655
  /**
656
656
  * Lists recent confirmed fixes for the UI (SPEC §14.1 fix points).
657
+ * The webapp's history window filters via `sinceMs` (epoch ms): rows
658
+ * older than the boundary are left out so the chart shows current
659
+ * chartwork, not everything since install.
657
660
  *
658
661
  * @param {import("node:sqlite").DatabaseSync} db
659
662
  * @param {object} [q]
660
663
  * @param {number} [q.limit=100]
664
+ * @param {number|null} [q.sinceMs] - include rows at/after this epoch ms
661
665
  * @returns {Array<object>} newest-first
662
666
  */
663
667
  function listFixes(db, q = {}) {
@@ -666,9 +670,35 @@ function listFixes(db, q = {}) {
666
670
  `SELECT fix_id, timestamp, source_type, latitude, longitude,
667
671
  estimated_error_radius, confirmed_by, resets_dr_origin,
668
672
  derived_from_fix_id
669
- FROM fixes ORDER BY fix_id DESC LIMIT ?`,
673
+ FROM fixes ${sinceClause(q)} ORDER BY fix_id DESC LIMIT ?`,
670
674
  )
671
- .all(q.limit ?? 100);
675
+ .all(...sinceArgs(q), q.limit ?? 100);
676
+ }
677
+
678
+ /**
679
+ * WHERE fragment for the optional `sinceMs` window filter, shared by
680
+ * the overlay list queries — fixes, LOPs, CPLs and corrections all
681
+ * carry the same ISO-8601 `timestamp` column. Compares via julianday()
682
+ * rather than string comparison: stored timestamps vary in shape
683
+ * (`…00Z` vs `…00.001Z` vs `…00.000Z`), and lexicographic order is
684
+ * wrong across those shapes (a bare `Z` sorts AFTER a fraction).
685
+ *
686
+ * @param {object} q - the query options carrying `sinceMs`
687
+ * @returns {string} "" or the WHERE clause (args from sinceArgs)
688
+ */
689
+ function sinceClause(q) {
690
+ return q.sinceMs != null ? "WHERE julianday(timestamp) >= julianday(?)" : "";
691
+ }
692
+
693
+ /**
694
+ * Positional args matching {@link sinceClause}; callers pass them to
695
+ * .all() BEFORE the limit.
696
+ *
697
+ * @param {object} q
698
+ * @returns {Array<unknown>}
699
+ */
700
+ function sinceArgs(q) {
701
+ return q.sinceMs != null ? [new Date(q.sinceMs).toISOString()] : [];
672
702
  }
673
703
 
674
704
  /**
@@ -709,10 +739,12 @@ function getCircularPositionLine(db, id) {
709
739
 
710
740
  /**
711
741
  * Lists persisted lines of position for the UI (SPEC §14.1 LOP overlay).
742
+ * `sinceMs` window-filters as in listFixes.
712
743
  *
713
744
  * @param {import("node:sqlite").DatabaseSync} db
714
745
  * @param {object} [q]
715
746
  * @param {number} [q.limit=100]
747
+ * @param {number|null} [q.sinceMs]
716
748
  * @returns {Array<object>} newest-first
717
749
  */
718
750
  function listLinesOfPosition(db, q = {}) {
@@ -720,9 +752,9 @@ function listLinesOfPosition(db, q = {}) {
720
752
  .prepare(
721
753
  `SELECT lop_id, timestamp, lop_type, assumed_lat, assumed_lon,
722
754
  azimuth_true, intercept_nm, body_or_object, used_in_fix_id
723
- FROM lines_of_position ORDER BY lop_id DESC LIMIT ?`,
755
+ FROM lines_of_position ${sinceClause(q)} ORDER BY lop_id DESC LIMIT ?`,
724
756
  )
725
- .all(q.limit ?? 100);
757
+ .all(...sinceArgs(q), q.limit ?? 100);
726
758
  }
727
759
 
728
760
  /**
@@ -732,6 +764,7 @@ function listLinesOfPosition(db, q = {}) {
732
764
  * @param {import("node:sqlite").DatabaseSync} db
733
765
  * @param {object} [q]
734
766
  * @param {number} [q.limit=100]
767
+ * @param {number|null} [q.sinceMs]
735
768
  * @returns {Array<object>} newest-first
736
769
  */
737
770
  function listCircularPositionLines(db, q = {}) {
@@ -739,18 +772,20 @@ function listCircularPositionLines(db, q = {}) {
739
772
  .prepare(
740
773
  `SELECT cpl_id, timestamp, center_lat, center_lon, radius_nm,
741
774
  source_object, used_in_fix_id
742
- FROM circular_position_lines ORDER BY cpl_id DESC LIMIT ?`,
775
+ FROM circular_position_lines ${sinceClause(q)} ORDER BY cpl_id DESC LIMIT ?`,
743
776
  )
744
- .all(q.limit ?? 100);
777
+ .all(...sinceArgs(q), q.limit ?? 100);
745
778
  }
746
779
 
747
780
  /**
748
781
  * Lists recent snap-to-fix corrections for the UI (SPEC §9.3/§14.1 —
749
782
  * dashed vector from pre-snap ghost position to confirmed fix).
783
+ * `sinceMs` window-filters as in listFixes.
750
784
  *
751
785
  * @param {import("node:sqlite").DatabaseSync} db
752
786
  * @param {object} [q]
753
787
  * @param {number} [q.limit=20]
788
+ * @param {number|null} [q.sinceMs]
754
789
  * @returns {Array<object>} newest-first
755
790
  */
756
791
  function listCorrections(db, q = {}) {
@@ -759,9 +794,9 @@ function listCorrections(db, q = {}) {
759
794
  `SELECT correction_id, timestamp, dr_lat, dr_lon, fix_lat, fix_lon,
760
795
  deviation_nm, deviation_bearing, dr_elapsed_seconds,
761
796
  sail_state, sea_state
762
- FROM dr_corrections ORDER BY correction_id DESC LIMIT ?`,
797
+ FROM dr_corrections ${sinceClause(q)} ORDER BY correction_id DESC LIMIT ?`,
763
798
  )
764
- .all(q.limit ?? 20);
799
+ .all(...sinceArgs(q), q.limit ?? 20);
765
800
  }
766
801
 
767
802
  // -------------------------------------------------------------------------
package/plugin/index.js CHANGED
@@ -323,6 +323,18 @@ const DEFAULT_CONFIG = {
323
323
  sustainS: 10,
324
324
  clearS: 10,
325
325
  },
326
+ /**
327
+ * Trip-boundary hysteresis (SPEC §9.2): how long navigation.state
328
+ * must STAY underway (leaving moored/anchored) before the transition
329
+ * counts as a trip start and resets the trip log. Longer than the
330
+ * sensor-health windows on purpose — untying, anchor aweigh and
331
+ * lunch stops all bounce the state for a few minutes — long enough
332
+ * that only real departure zeroes the trip log.
333
+ */
334
+ tripBoundary: {
335
+ sustainS: 300,
336
+ clearS: 300,
337
+ },
326
338
  /**
327
339
  * §3.1 inertial-polar fallback (work doc #18): running-average window
328
340
  * and staleness cutoff for the polar speed — both the
@@ -610,6 +622,27 @@ module.exports = (app) => {
610
622
  /** @type {number|null} monotonic seconds counter for the training loop */
611
623
  let clockS = 0;
612
624
 
625
+ /**
626
+ * Trip boundary tracking (SPEC §9.2): the debounced moored/anchored
627
+ * → underway transition resets the trip log and records the trip
628
+ * start. Debounced symmetric hysteresis (createFlagState/flagTick)
629
+ * so a flapping autostate source can't zero the trip log spuriously.
630
+ * `raw` is this tick's underway verdict; the flag RAISES on the
631
+ * sustained underway edge and never drops mid-trip — the boundary
632
+ * event is the transition INTO a trip, not the trip's end.
633
+ * @type {ReturnType<typeof createFlagState>|null}
634
+ */
635
+ let tripFlag = null;
636
+
637
+ /**
638
+ * Trip start timestamp (ms) of the current/last trip — persisted in
639
+ * dr_state_store so a mid-trip restart keeps the boundary (and the
640
+ * webapp's history window: max(7 days, since trip start)). Null when
641
+ * no boundary has ever been observed.
642
+ * @type {number|null}
643
+ */
644
+ let tripStartMs = null;
645
+
613
646
  /** @type {ReturnType<typeof createDivergenceState>|null} §7.3 divergence monitor */
614
647
  let divergence = null;
615
648
 
@@ -859,6 +892,10 @@ module.exports = (app) => {
859
892
  ...DEFAULT_CONFIG.sensorHealth,
860
893
  ...(opts.sensorHealth ?? {}),
861
894
  };
895
+ config.tripBoundary = {
896
+ ...DEFAULT_CONFIG.tripBoundary,
897
+ ...(opts.tripBoundary ?? {}),
898
+ };
862
899
  config.logbook = {
863
900
  ...DEFAULT_CONFIG.logbook,
864
901
  ...(opts.logbook ?? {}),
@@ -955,6 +992,14 @@ module.exports = (app) => {
955
992
  shadowGate = deps.createPublishGate({
956
993
  everyTicks: config.publish.everyTicks,
957
994
  });
995
+ // Trip boundary tracking (SPEC §9.2): fresh debounce state per
996
+ // start; the trip start itself survives restarts via dr_state_store
997
+ // so the webapp's history window keeps the trip boundary across a
998
+ // mid-trip restart.
999
+ tripFlag = deps.createFlagState();
1000
+ const savedTripStartMs = Number(deps.getState(db, "dr_trip_start_ms"));
1001
+ tripStartMs = Number.isFinite(savedTripStartMs) ? savedTripStartMs : null;
1002
+
958
1003
  polarState = deps.createPolarSpeedState();
959
1004
  polarModel = null;
960
1005
  polarLoadedId = null;
@@ -1461,6 +1506,30 @@ module.exports = (app) => {
1461
1506
  const navState = deltaState.get("navigation.state");
1462
1507
  const underway = navState !== "anchored" && navState !== "moored";
1463
1508
 
1509
+ // Trip boundary (SPEC §9.2): a sustained moored/anchored → underway
1510
+ // transition starts a trip — resets the trip log and records the
1511
+ // start. The flag raises once on the sustained edge and never drops
1512
+ // (a trip ends at the NEXT raise, not on the underway→moored edge),
1513
+ // so an afternoon of anchoring for lunch doesn't zero the log. Only
1514
+ // a reset that actually moves the count is recorded — restarting
1515
+ // underway (no prior observed boundary) must not fabricate one.
1516
+ const tripEdge = deps.flagTick(
1517
+ tripFlag,
1518
+ underway,
1519
+ config.tickIntervalMs / 1000,
1520
+ config.tripBoundary,
1521
+ );
1522
+ if (tripEdge.transition === "raise") {
1523
+ if (engine.tripLogNm > 0) {
1524
+ engine.resetTrip();
1525
+ tripStartMs = Date.now();
1526
+ deps.setState(db, "dr_trip_start_ms", String(tripStartMs));
1527
+ app.debug(
1528
+ `Trip boundary: navigation.state sustained underway — trip log reset, trip started ${new Date(tripStartMs).toISOString()}`,
1529
+ );
1530
+ }
1531
+ }
1532
+
1464
1533
  // GPS-derived motion (independent of the water-track sensors):
1465
1534
  // updated every tick, used to detect "idle but making way" below.
1466
1535
  updateGpsMotion(gps);
@@ -2725,6 +2794,11 @@ module.exports = (app) => {
2725
2794
  }
2726
2795
  deps.setState(db, "dr_log_nm", String(engine.logNm));
2727
2796
  deps.setState(db, "dr_trip_log_nm", String(engine.tripLogNm));
2797
+ // Trip boundary (SPEC §9.2) survives restarts: persisted at the
2798
+ // boundary tick AND on every flush (the boundary itself can't be
2799
+ // re-derived — a restart mid-trip sees only "underway").
2800
+ if (tripStartMs != null)
2801
+ deps.setState(db, "dr_trip_start_ms", String(tripStartMs));
2728
2802
  deps.setState(db, "dr_log_since_origin", String(engine.logNmSinceOrigin));
2729
2803
  // Sea trial 2026-09-06: elapsed time drives the current-knowledge
2730
2804
  // term of the uncertainty cone — a restart must not zero it, or the
@@ -2746,6 +2820,25 @@ module.exports = (app) => {
2746
2820
 
2747
2821
  // --- REST API ----------------------------------------------------------
2748
2822
 
2823
+ /**
2824
+ * Parses the optional `since` query parameter (ISO-8601 string or
2825
+ * epoch ms) of the overlay endpoints into the db layer's `sinceMs`
2826
+ * option. Invalid values are ignored (unfiltered) — the window is a
2827
+ * display concern, never a reason to 4xx the chart.
2828
+ *
2829
+ * @param {object} req - Express request
2830
+ * @returns {{sinceMs?: number}}
2831
+ */
2832
+ function sinceOpt(req) {
2833
+ const raw = req.query?.since;
2834
+ if (raw == null || raw === "") return {};
2835
+ const n = Number(raw);
2836
+ if (Number.isFinite(n) && n > 0) return { sinceMs: n };
2837
+ const t = Date.parse(String(raw));
2838
+ if (Number.isFinite(t)) return { sinceMs: t };
2839
+ return {};
2840
+ }
2841
+
2749
2842
  /**
2750
2843
  * Registers REST routes on the plugin router.
2751
2844
  *
@@ -2766,6 +2859,9 @@ module.exports = (app) => {
2766
2859
  origin: engine.origin,
2767
2860
  logNm: engine.logNm,
2768
2861
  tripLogNm: engine.tripLogNm,
2862
+ // SPEC §9.2 trip boundary (null when none observed yet): the
2863
+ // webapp's history window is max(7 days, since trip start).
2864
+ tripStartMs,
2769
2865
  elapsedSinceOriginS: engine.elapsedSinceOriginS,
2770
2866
  underwaySinceOriginS: engine.underwaySinceOriginS,
2771
2867
  binCount: matrix.count(),
@@ -2794,8 +2890,9 @@ module.exports = (app) => {
2794
2890
  });
2795
2891
 
2796
2892
  /**
2797
- * GET /fixes — recent confirmed fixes for the map overlay (SPEC
2798
- * §14.1 fix points). `limit` caps the result (default 100).
2893
+ * GET /fixes — confirmed fixes for the map overlay (SPEC §14.1 fix
2894
+ * points). `limit` caps the result (default 100); `since` (ISO-8601
2895
+ * or epoch ms) window-filters to the webapp's history window.
2799
2896
  */
2800
2897
  router.get("/fixes", (req, res) => {
2801
2898
  if (!engine || !db) {
@@ -2806,13 +2903,14 @@ module.exports = (app) => {
2806
2903
  1,
2807
2904
  Math.min(1000, Number(req.query?.limit) || 100),
2808
2905
  );
2809
- res.json({ fixes: deps.listFixes(db, { limit }) });
2906
+ res.json({ fixes: deps.listFixes(db, { limit, ...sinceOpt(req) }) });
2810
2907
  });
2811
2908
 
2812
2909
  /**
2813
2910
  * GET /observations — persisted LOPs and CPLs for the map overlay
2814
2911
  * (SPEC §14.1 geometric primitives). Unused/unresolved observations
2815
2912
  * (used_in_fix_id IS NULL) are marked so the UI can emphasize them.
2913
+ * `since` window-filters to the webapp's history window.
2816
2914
  */
2817
2915
  router.get("/observations", (req, res) => {
2818
2916
  if (!engine || !db) {
@@ -2823,15 +2921,17 @@ module.exports = (app) => {
2823
2921
  1,
2824
2922
  Math.min(1000, Number(req.query?.limit) || 100),
2825
2923
  );
2924
+ const since = sinceOpt(req);
2826
2925
  res.json({
2827
- lops: deps.listLinesOfPosition(db, { limit }),
2828
- cpls: deps.listCircularPositionLines(db, { limit }),
2926
+ lops: deps.listLinesOfPosition(db, { limit, ...since }),
2927
+ cpls: deps.listCircularPositionLines(db, { limit, ...since }),
2829
2928
  });
2830
2929
  });
2831
2930
 
2832
2931
  /**
2833
2932
  * GET /corrections — recent snap-to-fix corrections for the dashed
2834
- * vector overlay (SPEC §9.3, §14.1).
2933
+ * vector overlay (SPEC §9.3, §14.1). `since` window-filters to the
2934
+ * webapp's history window.
2835
2935
  */
2836
2936
  router.get("/corrections", (req, res) => {
2837
2937
  if (!engine || !db) {
@@ -2839,7 +2939,9 @@ module.exports = (app) => {
2839
2939
  return;
2840
2940
  }
2841
2941
  const limit = Math.max(1, Math.min(200, Number(req.query?.limit) || 20));
2842
- res.json({ corrections: deps.listCorrections(db, { limit }) });
2942
+ res.json({
2943
+ corrections: deps.listCorrections(db, { limit, ...sinceOpt(req) }),
2944
+ });
2843
2945
  });
2844
2946
 
2845
2947
  /**