@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 +5 -0
- package/package.json +1 -1
- package/public/dr-map-view.js +58 -46
- package/public/dr-viewmodel.js +50 -0
- package/tests/dr-route.test.js +109 -0
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
package/public/dr-map-view.js
CHANGED
|
@@ -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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
|
1877
|
-
|
|
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
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
|
|
2361
|
-
|
|
2362
|
-
weight: 2,
|
|
2360
|
+
weight: 2.5,
|
|
2361
|
+
opacity: 0.95,
|
|
2363
2362
|
})
|
|
2364
|
-
.bindTooltip(
|
|
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
|
|
package/public/dr-viewmodel.js
CHANGED
|
@@ -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}` →
|
package/tests/dr-route.test.js
CHANGED
|
@@ -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
|
+
});
|