@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 +5 -0
- package/package.json +1 -1
- package/tests/replay-passage.test.js +64 -0
- package/tools/replay-passage.js +67 -19
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
|
@@ -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
|
+
});
|
package/tools/replay-passage.js
CHANGED
|
@@ -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
|
|
864
|
-
let
|
|
865
|
-
let lonMinV = Infinity;
|
|
866
|
-
let lonMaxV = -Infinity;
|
|
899
|
+
let latMin = Infinity;
|
|
900
|
+
let latMax = -Infinity;
|
|
867
901
|
for (const p of pts) {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
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:
|
|
1062
|
-
latMax:
|
|
1063
|
-
lonMin:
|
|
1064
|
-
lonMax:
|
|
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,
|