@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -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
- ribbonSegments,
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: true,
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
- this._ribbon =
583
- (this._payload?.waypoints ?? []).filter(
584
- (w) => Number.isFinite(w?.lat) && Number.isFinite(w?.lon),
585
- ).length >= 2
586
- ? routeRibbon(
587
- this._payload.waypoints,
588
- this._ribbonRows ?? [],
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))
@@ -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 { eventPins, ribbonSegments, routeRibbon, fitPoints, vesselFitBounds } =
369
- await import("../public/components/map-model.mjs");
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
  () => {
@@ -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
+ });