@meri-imperiumi/signalk-dead-reckoning 0.13.0 → 0.13.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,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.1] - 2026-10-05
11
+
12
+ ### Fixed
13
+ - The passage replay tool renders and fetches the short way across the antimeridian (`tools/replay-passage.js`): a track crossing 180° used to smear across the whole 360° span in the SVG track plot — the naive longitude min/max bounds turned 179E → 179W into a world-wide box — and the SCUD current grid fetch computed its 0–360 request box from the same raw bounds, returning nothing useful. Longitudes are now unwrapped against the first fix before bounds are computed, and the SCUD box is built by the new exported `trackBox()` helper in the server's 0–360 frame
14
+
10
15
  ## [0.13.0] - 2026-10-05
11
16
 
12
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -22,6 +22,8 @@ const {
22
22
  runReplay,
23
23
  summarize,
24
24
  currentAt,
25
+ toHtmlReport,
26
+ trackBox,
25
27
  PATH_SPECS,
26
28
  } = require("../tools/replay-passage.js");
27
29
  const {
@@ -316,3 +318,65 @@ test("leeway synthetic: the learning variant absorbs a 4° course offset", () =>
316
318
  `learn div ${last.learn.divNm}`,
317
319
  );
318
320
  });
321
+
322
+ test("report plot: a track crossing the antimeridian renders contiguous", () => {
323
+ // A due-east track from 179°E crosses 180° and reads as -179° in the
324
+ // next fix (GPS wraps to [-180, 180]). The SVG plot must unwrap those
325
+ // longitudes so the polyline runs left-to-right across the seam —
326
+ // the naive min/max bounds used to smear the track across the whole
327
+ // 360° span.
328
+ const samples = [];
329
+ for (let i = 0; i < 20; i++) {
330
+ const lon = 179 + i * 0.2; // crosses 180 at i = 6
331
+ const wrapped = lon > 180 ? lon - 360 : lon;
332
+ samples.push({
333
+ tMs: Date.parse("2026-08-14T00:00:00Z") + i * DT_S * 1000,
334
+ gps: { latitude: -14, longitude: wrapped },
335
+ cold: {
336
+ position: { latitude: -14.001, longitude: wrapped },
337
+ divNm: 0.1 * i,
338
+ },
339
+ });
340
+ }
341
+ const summary = summarize(samples);
342
+ const html = toHtmlReport({
343
+ meta: {
344
+ vessel: "test",
345
+ from: "2026-08-14",
346
+ to: "2026-08-15",
347
+ historyUrl: "x",
348
+ },
349
+ samples,
350
+ summary,
351
+ hourly: [],
352
+ });
353
+ const polylines = html.match(/<polyline points="([^"]+)"/g);
354
+ assert.ok(polylines.length >= 1, "the report has a track polyline");
355
+ const gpsPts = polylines[0]
356
+ .match(/points="([^"]+)"/)[1]
357
+ .split(" ")
358
+ .map((p) => p.split(",").map(Number));
359
+ // x advances monotonically across the seam instead of jumping back
360
+ let monotonic = true;
361
+ for (let i = 1; i < gpsPts.length; i++) {
362
+ if (gpsPts[i][0] < gpsPts[i - 1][0] - 0.5) monotonic = false;
363
+ }
364
+ assert.ok(monotonic, "GPS polyline x is monotonic across the seam");
365
+ assert.ok(gpsPts[gpsPts.length - 1][0] > 800, "track spans the plot width");
366
+ });
367
+
368
+ test("trackBox: a seam-crossing track produces a contiguous SCUD box", () => {
369
+ // A track from 179E to 179W (wrapped at the seam by the GPS) must
370
+ // produce a box around 359-361 in the server's 0-360 frame — not the
371
+ // naive 179..359 span the raw min/max would request.
372
+ const track = [
373
+ { latitude: -14, longitude: 179.0 },
374
+ { latitude: -14.1, longitude: 179.5 },
375
+ { latitude: -14.2, longitude: -179.0 }, // wrapped at the seam
376
+ ];
377
+ const box = trackBox(track);
378
+ assert.ok(Math.abs(box.lonMin - 539) < 1e-9, `lonMin ${box.lonMin}`);
379
+ assert.ok(Math.abs(box.lonMax - 541) < 1e-9, `lonMax ${box.lonMax}`);
380
+ assert.ok(Math.abs(box.latMin - -14.2) < 1e-9);
381
+ assert.ok(Math.abs(box.latMax - -14) < 1e-9);
382
+ });
@@ -288,6 +288,42 @@ const SCUD_ERDDAP =
288
288
  /** Margin (deg) around the track bounds for the SCUD fetch box. */
289
289
  const SCUD_BOX_MARGIN_DEG = 0.5;
290
290
 
291
+ /**
292
+ * Bounds of a GPS track as SCUD fetch-box coordinates: latitude in
293
+ * degrees, longitude in the server's 0–360 east frame. Longitudes are
294
+ * unwrapped against the first fix so a track crossing the antimeridian
295
+ * stays contiguous (179E → 181, not −179): the naive min/max would
296
+ * request a box spanning the whole Pacific.
297
+ *
298
+ * @param {{latitude: number, longitude: number}[]} track
299
+ * @returns {{latMin: number, latMax: number, lonMin: number, lonMax: number}}
300
+ */
301
+ function trackBox(track) {
302
+ if (!track.length) {
303
+ return { latMin: 0, latMax: 0, lonMin: 360, lonMax: 360 };
304
+ }
305
+ const unwrap = (lon, ref) => lon - 360 * Math.round((lon - ref) / 360);
306
+ let latMin = Infinity;
307
+ let latMax = -Infinity;
308
+ let lonMinU = track[0].longitude;
309
+ let lonMaxU = lonMinU;
310
+ let prev = lonMinU;
311
+ for (const p of track) {
312
+ latMin = Math.min(latMin, p.latitude);
313
+ latMax = Math.max(latMax, p.latitude);
314
+ const u = unwrap(p.longitude, prev);
315
+ lonMinU = Math.min(lonMinU, u);
316
+ lonMaxU = Math.max(lonMaxU, u);
317
+ prev = u;
318
+ }
319
+ return {
320
+ latMin,
321
+ latMax,
322
+ lonMin: lonMinU + 360,
323
+ lonMax: lonMaxU + 360,
324
+ };
325
+ }
326
+
291
327
  /**
292
328
  * Fetches a SCUD current grid over a lat/lon box and time range.
293
329
  *
@@ -860,22 +896,34 @@ function toHtmlReport(report) {
860
896
  if (s.gps) pts.push(s.gps);
861
897
  for (const k of keys) if (s[k]?.position) pts.push(s[k].position);
862
898
  }
863
- let latMinV = Infinity;
864
- let latMaxV = -Infinity;
865
- let lonMinV = Infinity;
866
- let lonMaxV = -Infinity;
899
+ let latMin = Infinity;
900
+ let latMax = -Infinity;
867
901
  for (const p of pts) {
868
- latMinV = Math.min(latMinV, p.latitude);
869
- latMaxV = Math.max(latMaxV, p.latitude);
870
- lonMinV = Math.min(lonMinV, p.longitude);
871
- lonMaxV = Math.max(lonMaxV, p.longitude);
902
+ latMin = Math.min(latMin, p.latitude);
903
+ latMax = Math.max(latMax, p.latitude);
904
+ }
905
+ // Unwrap longitudes against the first point so a track crossing the
906
+ // antimeridian stays contiguous (179 -> 181 instead of 179 -> -179,
907
+ // which the naive min/max would smear across the whole 360° span)
908
+ const unwrap = (lon, ref) => lon - 360 * Math.round((lon - ref) / 360);
909
+ const lon0 = pts.length ? pts[0].longitude : 0;
910
+ let lonMin = Infinity;
911
+ let lonMax = -Infinity;
912
+ let prev = lon0;
913
+ for (const p of pts) {
914
+ const u = unwrap(p.longitude, prev);
915
+ lonMin = Math.min(lonMin, u);
916
+ lonMax = Math.max(lonMax, u);
917
+ prev = u;
918
+ }
919
+ if (!pts.length) {
920
+ lonMin = 0;
921
+ lonMax = 0;
922
+ latMin = 0;
923
+ latMax = 0;
872
924
  }
873
- const latMin = latMinV;
874
- const latMax = latMaxV;
875
- const lonMin = lonMinV;
876
- const lonMax = lonMaxV;
877
925
  const latMid = ((latMin + latMax) / 2) * (Math.PI / 180);
878
- const xOf = (lon) => (lon - lonMin) * Math.cos(latMid);
926
+ const xOf = (lon) => (unwrap(lon, lonMin) - lonMin) * Math.cos(latMid);
879
927
  const yOf = (lat) => latMax - lat;
880
928
  const spanX = Math.max(1e-9, xOf(lonMax));
881
929
  const spanY = Math.max(1e-9, yOf(latMin));
@@ -1050,18 +1098,17 @@ async function main(argv) {
1050
1098
  let scudGrid = null;
1051
1099
  if (opts.scud) {
1052
1100
  const track = rows.map((r) => r.position).filter(Boolean);
1053
- const lats = track.map((p) => p.latitude);
1054
- const lons = track.map((p) => p.longitude);
1101
+ const { latMin, latMax, lonMin, lonMax } = trackBox(track);
1055
1102
  const scudFrom = new Date(Date.parse(opts.from) - 86400000).toISOString();
1056
1103
  const scudTo = new Date(Date.parse(opts.to) + 86400000).toISOString();
1057
1104
  try {
1058
1105
  scudGrid = await fetchScudGrid({
1059
1106
  from: scudFrom,
1060
1107
  to: scudTo,
1061
- latMin: Math.min(...lats) - SCUD_BOX_MARGIN_DEG,
1062
- latMax: Math.max(...lats) + SCUD_BOX_MARGIN_DEG,
1063
- lonMin: Math.min(...lons) + 360 - SCUD_BOX_MARGIN_DEG,
1064
- lonMax: Math.max(...lons) + 360 + SCUD_BOX_MARGIN_DEG,
1108
+ latMin: latMin - SCUD_BOX_MARGIN_DEG,
1109
+ latMax: latMax + SCUD_BOX_MARGIN_DEG,
1110
+ lonMin: lonMin - SCUD_BOX_MARGIN_DEG,
1111
+ lonMax: lonMax + SCUD_BOX_MARGIN_DEG,
1065
1112
  });
1066
1113
  process.stderr.write(
1067
1114
  `SCUD grid: ${scudGrid.timesMs.length} days (${scudGrid.timesMs
@@ -1145,6 +1192,7 @@ module.exports = {
1145
1192
  forwardFill,
1146
1193
  fetchHistory,
1147
1194
  fetchScudGrid,
1195
+ trackBox,
1148
1196
  currentAt,
1149
1197
  runReplay,
1150
1198
  hourlyBuckets,