@meri-imperiumi/signalk-passage-briefing 0.13.2 → 0.13.3
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 +6 -0
- package/package.json +1 -1
- package/public/components/map-model.mjs +32 -0
- package/public/components/map-view.js +17 -15
- package/tests/map-model.test.js +76 -2
- package/tests/map-view.test.js +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.13.3] - 2026-10-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Every map overlay — route, timeline pins, warning regions, notes, cyclone graphics — vanished mid-pan when the view crossed the antimeridian: `worldCopyJump` silently teleports a drag across the ±180° seam back into the primary world copy, where the basemap repeats (tiles wrap every world) but the overlays do not — they are unwrapped into the boat's own world copy (work doc #47), which on a Tonga→New Zealand passage lies west of the seam, so one pan across it left everything drawn 360° off-screen until the next data-driven fit. The option stays off now; overlays slide naturally with the pan instead of disappearing from a map that looks unchanged.
|
|
9
|
+
- The map drew the simplified simulated track instead of the route's own geometry: the "one route line" refactor computed the colorized route ribbon and then a leftover assignment overwrote it with the sim-row ribbon (shipped in v0.12.8), so the drawn line drifted a mile or two off the actual route — the exact drift the refactor was meant to remove. The row-or-route selection moved into the model (`ribbonSpec`) and the view renders it in one assignment.
|
|
10
|
+
|
|
5
11
|
## [0.13.2] - 2026-10-19
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/package.json
CHANGED
|
@@ -870,6 +870,38 @@ export function routeRibbon(waypoints, track, referenceLon = null) {
|
|
|
870
870
|
return segments.map(({ key, ...segment }) => segment);
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
/**
|
|
874
|
+
* The one route line the map draws: the payload's real geometry
|
|
875
|
+
* colorized by the plan ({@link routeRibbon}) when it exists, the
|
|
876
|
+
* simulated rows ({@link ribbonSegments}) only without it (here mode
|
|
877
|
+
* and pre-compile views). The selection lives here — not in the view
|
|
878
|
+
* — so the row fallback cannot clobber the route line again (the
|
|
879
|
+
* discard regression of 2026-10: the view computed the route ribbon
|
|
880
|
+
* and then overwrote it with the rows).
|
|
881
|
+
*
|
|
882
|
+
* @param {Array<{lat: number, lon: number}>} waypoints - The viewed
|
|
883
|
+
* route's sampled geometry (empty in here mode)
|
|
884
|
+
* @param {Array<{distanceFromStartNm: number, lat: number, lon: number,
|
|
885
|
+
* comfortLevel: string|null, slatting: boolean, motoring: boolean,
|
|
886
|
+
* night: boolean}>} track - summary.track rows
|
|
887
|
+
* @param {number|null} referenceLon - The map's longitude frame
|
|
888
|
+
* @returns {Array<{points: number[][], comfortLevel: string|null,
|
|
889
|
+
* slatting: boolean, motoring: boolean, night: boolean}>}
|
|
890
|
+
*/
|
|
891
|
+
export function ribbonSpec(waypoints, track, referenceLon = null) {
|
|
892
|
+
const hasGeometry =
|
|
893
|
+
(waypoints ?? []).filter(
|
|
894
|
+
(w) => Number.isFinite(w?.lat) && Number.isFinite(w?.lon),
|
|
895
|
+
).length >= 2;
|
|
896
|
+
if (!hasGeometry) {
|
|
897
|
+
return ribbonSegments(track ?? [], referenceLon);
|
|
898
|
+
}
|
|
899
|
+
return (
|
|
900
|
+
routeRibbon(waypoints, track ?? [], referenceLon) ??
|
|
901
|
+
ribbonSegments(track ?? [], referenceLon)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
873
905
|
/**
|
|
874
906
|
* The coordinates the map's initial fit frames (work doc #47 session:
|
|
875
907
|
* the fit is the route's, not the warnings'). When route geometry
|
|
@@ -44,8 +44,7 @@ import {
|
|
|
44
44
|
OSM_LAYER,
|
|
45
45
|
parseChartLayers,
|
|
46
46
|
regionShapes,
|
|
47
|
-
|
|
48
|
-
routeRibbon,
|
|
47
|
+
ribbonSpec,
|
|
49
48
|
stormGraphic,
|
|
50
49
|
stormRingColor,
|
|
51
50
|
vesselFitBounds,
|
|
@@ -159,7 +158,14 @@ class PbMapView extends HTMLElement {
|
|
|
159
158
|
// that far anyway
|
|
160
159
|
minZoom: 2,
|
|
161
160
|
attributionControl: false,
|
|
162
|
-
worldCopyJump:
|
|
161
|
+
// worldCopyJump stays OFF on purpose: every overlay is unwrapped
|
|
162
|
+
// into the boat's own world copy (work doc #47), which on a
|
|
163
|
+
// Tonga→New Zealand passage is the western one beyond the
|
|
164
|
+
// antimeridian. The jump silently teleports a drag across the
|
|
165
|
+
// seam back into the primary copy — tiles repeat every world so
|
|
166
|
+
// the basemap looks unchanged, but the route, pins and warning
|
|
167
|
+
// shapes do not repeat, and everything is suddenly 360°
|
|
168
|
+
// off-screen until the next data-driven fit
|
|
163
169
|
});
|
|
164
170
|
L.control.zoom({ position: "bottomleft" }).addTo(this.map);
|
|
165
171
|
// On touch the collapsed gear is one more tap between the crew and
|
|
@@ -578,17 +584,14 @@ class PbMapView extends HTMLElement {
|
|
|
578
584
|
// One route line (work doc #47 session): the real route geometry
|
|
579
585
|
// carrying the plan's color encoding — not a plain route line
|
|
580
586
|
// plus a second sim-track ribbon that drifts off it. The row
|
|
581
|
-
// ribbon survives only where no route geometry exists
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
referenceLon,
|
|
590
|
-
)
|
|
591
|
-
: ribbonSegments(this._ribbonRows ?? [], referenceLon);
|
|
587
|
+
// ribbon survives only where no route geometry exists; the
|
|
588
|
+
// selection lives in the model (ribbonSpec) so no second
|
|
589
|
+
// assignment can clobber the route line again
|
|
590
|
+
this._ribbon = ribbonSpec(
|
|
591
|
+
this._payload?.waypoints ?? [],
|
|
592
|
+
this._ribbonRows ?? [],
|
|
593
|
+
referenceLon,
|
|
594
|
+
);
|
|
592
595
|
this._regions = regionShapes(this._resourceRegions, referenceLon);
|
|
593
596
|
this._noteMarkers = notePins(
|
|
594
597
|
this._resourceNotes,
|
|
@@ -601,7 +604,6 @@ class PbMapView extends HTMLElement {
|
|
|
601
604
|
this._consoleIndex = new Map(
|
|
602
605
|
(this._blocks ?? []).map((block, index) => [block.text, index]),
|
|
603
606
|
);
|
|
604
|
-
this._ribbon = ribbonSegments(this._ribbonRows ?? [], referenceLon);
|
|
605
607
|
this._eventPins = eventPins(this._events ?? [], referenceLon);
|
|
606
608
|
this._storms = (this._blocks ?? [])
|
|
607
609
|
.map((block, index) => stormGraphic(block, index, referenceLon))
|
package/tests/map-model.test.js
CHANGED
|
@@ -365,8 +365,14 @@ test("map model", async (t) => {
|
|
|
365
365
|
});
|
|
366
366
|
|
|
367
367
|
test("ribbon segments and event pins (work doc #47)", async (t) => {
|
|
368
|
-
const {
|
|
369
|
-
|
|
368
|
+
const {
|
|
369
|
+
eventPins,
|
|
370
|
+
ribbonSegments,
|
|
371
|
+
ribbonSpec,
|
|
372
|
+
routeRibbon,
|
|
373
|
+
fitPoints,
|
|
374
|
+
vesselFitBounds,
|
|
375
|
+
} = await import("../public/components/map-model.mjs");
|
|
370
376
|
|
|
371
377
|
await t.test("ribbonSegments run-length groups the plan's regimes", () => {
|
|
372
378
|
const track = [
|
|
@@ -562,6 +568,74 @@ test("ribbon segments and event pins (work doc #47)", async (t) => {
|
|
|
562
568
|
assert.equal(routeRibbon([{ lat: 1, lon: 2 }], []), null);
|
|
563
569
|
});
|
|
564
570
|
|
|
571
|
+
await t.test(
|
|
572
|
+
"ribbonSpec draws the route geometry, rows only without it",
|
|
573
|
+
() => {
|
|
574
|
+
// The one-line rule the view renders by: the payload's real
|
|
575
|
+
// geometry colorized by the plan when it exists, the simulated
|
|
576
|
+
// rows only without it (here mode, pre-compile). The selection
|
|
577
|
+
// lives in the model so the row fallback cannot clobber the
|
|
578
|
+
// route line again — the 2026-10 discard regression had the
|
|
579
|
+
// view compute the route ribbon and then overwrite it with the
|
|
580
|
+
// rows, drawing the simplified sim-track line that drifts off
|
|
581
|
+
// the route instead of the route itself
|
|
582
|
+
const waypoints = [
|
|
583
|
+
{ lat: -18.6, lon: -174, distanceFromStartNm: 0 },
|
|
584
|
+
{ lat: -18.8, lon: -174.4, distanceFromStartNm: 30 },
|
|
585
|
+
];
|
|
586
|
+
const track = [
|
|
587
|
+
{
|
|
588
|
+
distanceFromStartNm: 0,
|
|
589
|
+
lat: -18.6,
|
|
590
|
+
lon: -174,
|
|
591
|
+
comfortLevel: "pleasant",
|
|
592
|
+
slatting: false,
|
|
593
|
+
motoring: false,
|
|
594
|
+
night: false,
|
|
595
|
+
},
|
|
596
|
+
];
|
|
597
|
+
const spec = ribbonSpec(waypoints, track);
|
|
598
|
+
assert.deepEqual(
|
|
599
|
+
spec[0].points[0],
|
|
600
|
+
[-174, -18.6],
|
|
601
|
+
"route geometry drives the line",
|
|
602
|
+
);
|
|
603
|
+
assert.equal(spec[0].comfortLevel, "pleasant");
|
|
604
|
+
// No route geometry: the row ribbon survives
|
|
605
|
+
const rowsOnly = ribbonSpec([], track);
|
|
606
|
+
assert.deepEqual(rowsOnly, ribbonSegments(track));
|
|
607
|
+
assert.equal(rowsOnly.length, 1);
|
|
608
|
+
// Null payloads (pre-compile views) degrade to the rows too
|
|
609
|
+
assert.deepEqual(ribbonSpec(null, track), ribbonSegments(track));
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
await t.test(
|
|
614
|
+
"ribbonSpec unwraps the route into the reference frame across the antimeridian",
|
|
615
|
+
() => {
|
|
616
|
+
// The Tonga→New Zealand passage straddles ±180°: with the map
|
|
617
|
+
// framed on the boat (first waypoint -175), the plan's eastern
|
|
618
|
+
// points must draw as their western-copy equivalents so route
|
|
619
|
+
// and warnings share one world copy
|
|
620
|
+
const waypoints = [
|
|
621
|
+
{ lat: -19.05, lon: -175.4, distanceFromStartNm: 0 },
|
|
622
|
+
{ lat: -20.3, lon: -179.9, distanceFromStartNm: 300 },
|
|
623
|
+
{ lat: -21.5, lon: 175.2, distanceFromStartNm: 600 },
|
|
624
|
+
{ lat: -22.4, lon: 173.8, distanceFromStartNm: 900 },
|
|
625
|
+
];
|
|
626
|
+
const spec = ribbonSpec(waypoints, [], -175.4);
|
|
627
|
+
const lons = spec[0].points.map(([lon]) => lon);
|
|
628
|
+
assert.ok(
|
|
629
|
+
lons.every((lon, i) => i === 0 || lon < lons[i - 1] + 180),
|
|
630
|
+
`one continuous frame: ${JSON.stringify(lons)}`,
|
|
631
|
+
);
|
|
632
|
+
assert.ok(
|
|
633
|
+
lons.at(-1) < -180,
|
|
634
|
+
`New Zealand lands in the western copy: ${lons.at(-1)}`,
|
|
635
|
+
);
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
|
|
565
639
|
await t.test(
|
|
566
640
|
"fitPoints frames the route, then the vessel, then the warnings",
|
|
567
641
|
() => {
|
package/tests/map-view.test.js
CHANGED
|
@@ -156,3 +156,52 @@ test("maplibreStyleFor builds a source-and-trio style for a vector chart", async
|
|
|
156
156
|
);
|
|
157
157
|
assert.ok(seamarkIdx > waterIdx, "seamarks above water fills");
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("the map view mounts Leaflet without view-copy teleporting", async () => {
|
|
161
|
+
// Leaflet only mounts in a browser, so the component itself has no
|
|
162
|
+
// node-test harness — these two mount/render regressions are pinned
|
|
163
|
+
// at the source level instead.
|
|
164
|
+
//
|
|
165
|
+
// worldCopyJump silently wraps a drag across the antimeridian back
|
|
166
|
+
// into the primary world copy: tiles repeat every world so the
|
|
167
|
+
// basemap looks unchanged, but the overlays are unwrapped into the
|
|
168
|
+
// boat's own copy (work doc #47) and do not repeat — on a
|
|
169
|
+
// Tonga→New Zealand passage (route astride ±180°) one pan across
|
|
170
|
+
// the seam left every route, pin and warning shape 360° off-screen
|
|
171
|
+
// until the next data-driven fit. The option must stay off.
|
|
172
|
+
const { readFileSync } = require("node:fs");
|
|
173
|
+
const { join } = require("node:path");
|
|
174
|
+
const source = readFileSync(
|
|
175
|
+
join(__dirname, "../public/components/map-view.js"),
|
|
176
|
+
"utf8",
|
|
177
|
+
);
|
|
178
|
+
assert.ok(
|
|
179
|
+
!/worldCopyJump\s*:\s*true/.test(source),
|
|
180
|
+
"worldCopyJump must not be enabled — it teleports the view off the boat's overlay frame mid-drag",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the view renders the ribbon through the model's one-line selector", async () => {
|
|
185
|
+
// The 2026-10 discard regression: the view computed the route-geometry
|
|
186
|
+
// ribbon (routeRibbon) and then overwrote it with the sim-row ribbon
|
|
187
|
+
// (ribbonSegments) — the map drew the simplified sim track that drifts
|
|
188
|
+
// off the route instead of the route itself. The selection now lives in
|
|
189
|
+
// map-model's ribbonSpec; the view must call it once and never assign
|
|
190
|
+
// _ribbon again. The only allowed assignments are the pre-mount []
|
|
191
|
+
// initializer and the render's ribbonSpec call.
|
|
192
|
+
const { readFileSync } = require("node:fs");
|
|
193
|
+
const { join } = require("node:path");
|
|
194
|
+
const source = readFileSync(
|
|
195
|
+
join(__dirname, "../public/components/map-view.js"),
|
|
196
|
+
"utf8",
|
|
197
|
+
);
|
|
198
|
+
const initializers = [...source.matchAll(/this\._ribbon\s*=\s*(\S)/g)].map(
|
|
199
|
+
(match) => match[1],
|
|
200
|
+
);
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
initializers,
|
|
203
|
+
["[", "r"],
|
|
204
|
+
`the only _ribbon assignments must be the [] initializer and the render's ribbonSpec call (found: ${JSON.stringify(initializers)})`,
|
|
205
|
+
);
|
|
206
|
+
assert.match(source, /ribbonSpec\(/, "the selector comes from the model");
|
|
207
|
+
});
|