@meri-imperiumi/signalk-dead-reckoning 0.14.1 → 0.14.2

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.14.2] - 2026-10-19
11
+
12
+ ### Fixed
13
+ - **Routes and tracks crossing the antimeridian no longer draw a line around the planet.** An active-route leg straddling ±180° longitude (Fiji↔Samoa, Tonga's eastern islands, the Bering Strait) rendered as a horizontal stroke spanning the whole chart width — Leaflet joins two raw longitudes straight through the primary world frame instead of hopping the seam. Route lines draw unwrapped (`vm.unwrapLineLongitudes`: every point shifted ±360° onto the previous point's longitude strip) and repeat on the ±360° world copies the view can sit over (`vm.worldCopies`), so the leg renders as the short hop over the dateline with waypoint markers and the "next" emphasis intact; the ghost (DR) and GPS tracks get the same treatment, as a boat logging fixes across the seam produced the identical artifact in its track.
14
+
10
15
  ## [0.14.1] - 2026-10-19
11
16
 
12
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -1840,28 +1840,32 @@ class DrMapView extends HTMLElement {
1840
1840
  this.layers.ghost?.clearLayers();
1841
1841
  if (pts.length < 2) return;
1842
1842
  const t = vm.STYLE.track;
1843
- L.polyline(pts, {
1844
- color: t.casingColor,
1845
- weight: t.ghostWeight + t.casingWidth,
1846
- opacity: 0.85,
1847
- interactive: false,
1848
- }).addTo(this.layers.ghost);
1849
- L.polyline(pts, {
1850
- color: vm.STYLE.ghostTrack,
1851
- weight: t.ghostWeight,
1852
- opacity: 0.95,
1853
- interactive: false,
1854
- }).addTo(this.layers.ghost);
1855
1843
  const course = vm.courseText(movement);
1856
- if (course) {
1857
- L.marker(pts[pts.length - 2], {
1858
- icon: L.divIcon({
1859
- className: "dr-course-label",
1860
- html: `<span>${course}</span>`,
1861
- }),
1844
+ // Unwrapped across the antimeridian and drawn per world copy —
1845
+ // the same seam handling as the active route.
1846
+ for (const line of vm.worldCopies(vm.unwrapLineLongitudes(pts))) {
1847
+ L.polyline(line, {
1848
+ color: t.casingColor,
1849
+ weight: t.ghostWeight + t.casingWidth,
1850
+ opacity: 0.85,
1851
+ interactive: false,
1852
+ }).addTo(this.layers.ghost);
1853
+ L.polyline(line, {
1854
+ color: vm.STYLE.ghostTrack,
1855
+ weight: t.ghostWeight,
1856
+ opacity: 0.95,
1862
1857
  interactive: false,
1863
- keyboard: false,
1864
1858
  }).addTo(this.layers.ghost);
1859
+ if (course) {
1860
+ L.marker(line[line.length - 2], {
1861
+ icon: L.divIcon({
1862
+ className: "dr-course-label",
1863
+ html: `<span>${course}</span>`,
1864
+ }),
1865
+ interactive: false,
1866
+ keyboard: false,
1867
+ }).addTo(this.layers.ghost);
1868
+ }
1865
1869
  }
1866
1870
  }
1867
1871
 
@@ -1873,8 +1877,11 @@ class DrMapView extends HTMLElement {
1873
1877
  */
1874
1878
  replacePolyline(key, pts, opts) {
1875
1879
  this.layers[key]?.clearLayers();
1876
- if (pts.length >= 2) {
1877
- L.polyline(pts, opts).addTo(this.layers[key]);
1880
+ if (pts.length < 2) return;
1881
+ // Tracks draw unwrapped across the antimeridian, per world copy
1882
+ // (same seam handling as the ghost track).
1883
+ for (const line of vm.worldCopies(vm.unwrapLineLongitudes(pts))) {
1884
+ L.polyline(line, opts).addTo(this.layers[key]);
1878
1885
  }
1879
1886
  }
1880
1887
 
@@ -2325,7 +2332,10 @@ class DrMapView extends HTMLElement {
2325
2332
  * point currently being navigated to emphasized. Dr-app pushes a
2326
2333
  * `routeRenderSpec`; null (course cleared, or a destination that
2327
2334
  * isn't a route) empties the layer. The whole layer is toggleable
2328
- * via the layers control ("Active route").
2335
+ * via the layers control ("Active route"). The line draws unwrapped
2336
+ * across the antimeridian (each leg the short way over the ±180°
2337
+ * seam, repeated on the world copies the view can sit over) — a raw
2338
+ * polyline renders a crossing leg as a line around the planet.
2329
2339
  *
2330
2340
  * @param {{name: string, points: Array<[number, number]>,
2331
2341
  * targetIndex: number, labels?: Array<string>}|null} spec
@@ -2336,33 +2346,35 @@ class DrMapView extends HTMLElement {
2336
2346
  if (!layer) return;
2337
2347
  layer.clearLayers();
2338
2348
  if (!spec || spec.points?.length < 2) return;
2339
- // Route line over a dark casing — same cartographic trick as the
2340
- // ghost track so the magenta stays legible over light raster tiles.
2341
- L.polyline(spec.points, {
2342
- color: vm.STYLE.track.casingColor,
2343
- weight: 4.5,
2344
- opacity: 0.85,
2345
- interactive: false,
2346
- }).addTo(layer);
2347
- L.polyline(spec.points, {
2348
- color: vm.STYLE.route,
2349
- weight: 2.5,
2350
- opacity: 0.95,
2351
- })
2352
- .bindTooltip(spec.name, { direction: "top" })
2353
- .addTo(layer);
2354
- for (let i = 0; i < spec.points.length; i++) {
2355
- const target = i === spec.targetIndex;
2356
- const label = spec.labels?.[i] ?? `WP ${i + 1}`;
2357
- L.circleMarker(spec.points[i], {
2358
- radius: target ? 6 : 4,
2349
+ for (const pts of vm.worldCopies(vm.unwrapLineLongitudes(spec.points))) {
2350
+ // Route line over a dark casing — same cartographic trick as the
2351
+ // ghost track so the magenta stays legible over light raster tiles.
2352
+ L.polyline(pts, {
2353
+ color: vm.STYLE.track.casingColor,
2354
+ weight: 4.5,
2355
+ opacity: 0.85,
2356
+ interactive: false,
2357
+ }).addTo(layer);
2358
+ L.polyline(pts, {
2359
2359
  color: vm.STYLE.route,
2360
- fillColor: target ? vm.STYLE.route : "transparent",
2361
- fillOpacity: target ? 0.9 : 0,
2362
- weight: 2,
2360
+ weight: 2.5,
2361
+ opacity: 0.95,
2363
2362
  })
2364
- .bindTooltip(target ? `${label} — next` : label, { direction: "top" })
2363
+ .bindTooltip(spec.name, { direction: "top" })
2365
2364
  .addTo(layer);
2365
+ for (let i = 0; i < pts.length; i++) {
2366
+ const target = i === spec.targetIndex;
2367
+ const label = spec.labels?.[i] ?? `WP ${i + 1}`;
2368
+ L.circleMarker(pts[i], {
2369
+ radius: target ? 6 : 4,
2370
+ color: vm.STYLE.route,
2371
+ fillColor: target ? vm.STYLE.route : "transparent",
2372
+ fillOpacity: target ? 0.9 : 0,
2373
+ weight: 2,
2374
+ })
2375
+ .bindTooltip(target ? `${label} — next` : label, { direction: "top" })
2376
+ .addTo(layer);
2377
+ }
2366
2378
  }
2367
2379
  }
2368
2380
 
@@ -2401,6 +2401,56 @@ export function aisTargetsForRender(store, nowMs, own, opts = {}) {
2401
2401
  return specs;
2402
2402
  }
2403
2403
 
2404
+ /**
2405
+ * Unwraps a polyline's longitudes to a continuous strip: every point
2406
+ * after the first is shifted by ±360° so no leg spans more than 180°
2407
+ * of longitude. On the chart this makes an antimeridian-crossing leg
2408
+ * draw as the short hop over the ±180° seam — Leaflet projects
2409
+ * unwrapped longitudes onto the neighbouring world copies — instead
2410
+ * of a straight line around the planet. Takes [lat, lng] points as
2411
+ * the map's render specs carry them; the input list is untouched.
2412
+ *
2413
+ * @param {Array<[number, number]>} pts
2414
+ * @returns {Array<[number, number]>} unwrapped copy
2415
+ */
2416
+ export function unwrapLineLongitudes(pts) {
2417
+ const out = [];
2418
+ for (const pt of pts) {
2419
+ let lng = pt[1];
2420
+ if (out.length > 0) {
2421
+ const prev = out[out.length - 1][1];
2422
+ while (lng - prev > 180) lng -= 360;
2423
+ while (lng - prev < -180) lng += 360;
2424
+ }
2425
+ out.push([pt[0], lng]);
2426
+ }
2427
+ return out;
2428
+ }
2429
+
2430
+ /**
2431
+ * The world copies of an unwrapped line the chart must draw for the
2432
+ * geometry to show from any view frame: the line itself, plus a
2433
+ * ±360°-shifted sibling whenever it crosses out of the primary
2434
+ * [-180, 180] frame — the rest of the chartwork (vessel, fixes,
2435
+ * waypoints) plots raw longitudes, so either side of the seam is a
2436
+ * legitimate frame to look at the line from.
2437
+ *
2438
+ * @param {Array<[number, number]>} pts - unwrapped [lat, lng] points
2439
+ * @returns {Array<Array<[number, number]>>} one point list per copy
2440
+ */
2441
+ export function worldCopies(pts) {
2442
+ let min = Infinity;
2443
+ let max = -Infinity;
2444
+ for (const pt of pts) {
2445
+ if (pt[1] < min) min = pt[1];
2446
+ if (pt[1] > max) max = pt[1];
2447
+ }
2448
+ const copies = [pts];
2449
+ if (max > 180) copies.push(pts.map(([lat, lng]) => [lat, lng - 360]));
2450
+ if (min < -180) copies.push(pts.map(([lat, lng]) => [lat, lng + 360]));
2451
+ return copies;
2452
+ }
2453
+
2404
2454
  /**
2405
2455
  * Extracts the resource id from a Signal K resource href —
2406
2456
  * `/resources/routes/{id}` → `{id}`, `/resources/waypoints/{id}` →
@@ -11,6 +11,8 @@
11
11
 
12
12
  const test = require("node:test");
13
13
  const assert = require("node:assert/strict");
14
+ const { readFileSync } = require("node:fs");
15
+ const { join } = require("node:path");
14
16
 
15
17
  const vmPromise = import("../public/dr-viewmodel.js");
16
18
 
@@ -211,3 +213,110 @@ test("routeWaypointLabels uses coordinatesMeta names, 1-based WP fallback", asyn
211
213
  assert.deepEqual(vm.routeWaypointLabels(null, 2), ["WP 1", "WP 2"]);
212
214
  assert.deepEqual(vm.routeWaypointLabels({}, 1), ["WP 1"]);
213
215
  });
216
+
217
+ test("unwrapLineLongitudes leaves an in-frame line untouched", async () => {
218
+ const vm = await loadVm();
219
+ const line = [
220
+ [60.1, 24.9],
221
+ [60.2, 24.8],
222
+ [60.3, 24.7],
223
+ ];
224
+ assert.deepEqual(vm.unwrapLineLongitudes(line), line);
225
+ });
226
+
227
+ test("unwrapLineLongitudes carries an antimeridian leg the short way", async () => {
228
+ const vm = await loadVm();
229
+ // Fiji → Samoa eastward across the seam: -179.9 must come out as
230
+ // 180.1 so the leg hops the dateline instead of wrapping the planet.
231
+ const crossing = [
232
+ [-17.8, 179.9],
233
+ [-17.7, -179.9],
234
+ ];
235
+ assert.deepEqual(vm.unwrapLineLongitudes(crossing), [
236
+ [-17.8, 179.9],
237
+ [-17.7, 180.1],
238
+ ]);
239
+ // …and westward the mirror image: 179.9 comes out as -180.1.
240
+ assert.deepEqual(vm.unwrapLineLongitudes([...crossing].reverse()), [
241
+ [-17.7, -179.9],
242
+ [-17.8, -180.1],
243
+ ]);
244
+ });
245
+
246
+ test("unwrapLineLongitudes keeps a multi-leg route continuous", async () => {
247
+ const vm = await loadVm();
248
+ // Steps of 1° east from 179°: the whole tail beyond the seam stays
249
+ // on one continuous strip (…, 180, 181, 182, 183), every leg 1°
250
+ // wide, no wrap-around jumps.
251
+ const pts = [
252
+ [0, 179],
253
+ [0, -180],
254
+ [0, -179],
255
+ [0, -178],
256
+ [0, -177],
257
+ ];
258
+ const unwrapped = vm.unwrapLineLongitudes(pts);
259
+ for (let i = 1; i < unwrapped.length; i++) {
260
+ const d = unwrapped[i][1] - unwrapped[i - 1][1];
261
+ assert.ok(Math.abs(d - 1) < 1e-9, `leg ${i} spans ${d}°, expected 1°`);
262
+ }
263
+ assert.equal(unwrapped[unwrapped.length - 1][1], 183);
264
+ });
265
+
266
+ test("worldCopies adds only the neighbours a seam-crossing line needs", async () => {
267
+ const vm = await loadVm();
268
+ // In-frame line: a single copy, the line itself.
269
+ const plain = [
270
+ [60, 24],
271
+ [61, 25],
272
+ ];
273
+ assert.deepEqual(vm.worldCopies(plain), [plain]);
274
+ // Eastward across +180: the -360° sibling covers the west-side view.
275
+ const overEast = [
276
+ [-17.8, 179.9],
277
+ [-17.7, 180.1],
278
+ ];
279
+ assert.deepEqual(vm.worldCopies(overEast), [
280
+ overEast,
281
+ [
282
+ [-17.8, -180.1],
283
+ [-17.7, -179.9],
284
+ ],
285
+ ]);
286
+ // Westward across -180: the +360° sibling covers the east-side view.
287
+ const overWest = [
288
+ [-17.7, -179.9],
289
+ [-17.8, -180.1],
290
+ ];
291
+ assert.deepEqual(vm.worldCopies(overWest), [
292
+ overWest,
293
+ [
294
+ [-17.7, 180.1],
295
+ [-17.8, 179.9],
296
+ ],
297
+ ]);
298
+ });
299
+
300
+ test("dr-map-view draws the route and tracks through the seam helpers", () => {
301
+ // Source smoketest (no DOM in the test env): the Leaflet adapter
302
+ // must run every seam-spanning line — active route, ghost track,
303
+ // GPS track — through unwrapLineLongitudes + worldCopies, or an
304
+ // antimeridian leg renders as a line around the planet again.
305
+ const mapSrc = readFileSync(join(__dirname, "../public/dr-map-view.js"), {
306
+ encoding: "utf8",
307
+ });
308
+ for (const name of [
309
+ "renderRoute(spec) {",
310
+ "renderGhostTrack(pts, movement = null) {",
311
+ "replacePolyline(key, pts, opts) {",
312
+ ]) {
313
+ const at = mapSrc.indexOf(name);
314
+ assert.ok(at > -1, `${name} exists`);
315
+ const body = mapSrc.slice(at, at + 2000);
316
+ assert.match(
317
+ body,
318
+ /vm\.worldCopies\(vm\.unwrapLineLongitudes\(/,
319
+ `${name} must unwrap longitudes and draw world copies`,
320
+ );
321
+ }
322
+ });