@meri-imperiumi/signalk-dead-reckoning 0.12.0 → 0.12.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,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.2] - 2026-09-28
11
+
12
+ ### Fixed
13
+ - **The top-right plotter-extension area now behaves as a top-right
14
+ anchor.** Two defects made it read as "top left of the GPS status
15
+ bar": the area rode its column's left edge instead of the GPS
16
+ panel's right edge (it now shares the panel's right alignment), and
17
+ the widget grid rendered logical col 0 at the physical left for
18
+ every anchor — so the first placed widget sat farthest from the
19
+ viewport corner with the free slots between it and the edge.
20
+ Right-hand anchors now mirror the column axis: col 0 hugs the
21
+ right-hand corner and widgets pack corner-inward, symmetric with
22
+ the top-left area.
23
+
24
+ ## [0.12.1] - 2026-09-28
25
+
26
+ ### Changed
27
+ - **Notes plot by default.** The Notes overlay started detached — the
28
+ markers were fetched and cached, but the layer only mounted when the
29
+ layers-control checkbox was flipped, so chart annotations (often
30
+ navigational warnings, e.g. METAREA gale warnings delivered by
31
+ signalk-passage-briefing) never showed until manually enabled.
32
+ Notes now mount with the map like AIS traffic and the active route;
33
+ the checkbox remains for decluttering, and a previously stored
34
+ "off" pick is still honored.
35
+ - **Own-ship boat glyphs are bigger and bolder.** The GPS and DR boats
36
+ grew from 20 to 26 px, the hull fill is near-solid (0.92 opacity, was
37
+ 0.35) under a heavier stroke, and the hull rides on a dark casing
38
+ outline — the same cartographic trick as the ghost track — so own
39
+ ship stays legible over light raster charts and chart clutter. The
40
+ navigator's X inside the DR hull got a matching weight bump.
41
+
42
+ ### Fixed
43
+ - **Notes show their full text.** The note detail surface (and the
44
+ edit form) read only the v2 notes `body` field — provider-shaped
45
+ notes (metarea warnings via signalk-passage-briefing) carry their
46
+ text in `description`, with the title truncated by the source, so
47
+ they opened with a "…" headline and no text at all. The text now
48
+ falls back to `description`. Editing such a note seeds the form with
49
+ the real text, and a save merges the stored resource so provider
50
+ metadata (properties, synoptic-chart `url`) survives the PUT.
51
+ - **Notes now load for the area being viewed.** The webapp fetched the
52
+ notes collection exactly once (boot and stream reconnect) with no
53
+ viewport information; resource-provider backends filter the
54
+ collection themselves, so the answer could be limited to the
55
+ provider's default area — zooming out showed no notes at all. The
56
+ chart now re-fetches notes for the visible area whenever the view
57
+ moves beyond the already-fetched region: Freeboard-SK's wire format
58
+ (`GET /signalk/v2/api/resources/notes?position=[lon,lat]&distance=<m>`
59
+ — view center plus a covering radius to the view corner), debounced,
60
+ with follow-mode re-centers not re-issuing the request. Scoped
61
+ responses merge into the notes cache, so notes from previously
62
+ viewed areas stay on the chart.
63
+
64
+ ### Added
65
+ - **The layers control remembers your choices.** Base chart and
66
+ overlay toggles (AIS traffic, active route, vectors, range rings,
67
+ laylines, notes) persist in localStorage (`dr-layers`) and are
68
+ restored on the next session — remembered picks that still mount
69
+ outrank the defaults; un-flipped toggles keep their built-in state.
70
+
10
71
  ## [0.12.0] - 2026-09-21
11
72
 
12
73
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -27,7 +27,7 @@
27
27
  "signalk-plugin-enabled-by-default": false,
28
28
  "signalk": {
29
29
  "displayName": "Dead Reckoning",
30
- "appIcon": "./icon.png",
30
+ "appIcon": "./icon-256.png",
31
31
  "requires": [
32
32
  "@meri-imperiumi/signalk-internet"
33
33
  ],
package/public/dr-app.js CHANGED
@@ -256,7 +256,13 @@ template.innerHTML = /* html */ `
256
256
  gap: 0.5rem;
257
257
  align-items: flex-start;
258
258
  }
259
- .dr-top-col > .dr-gps {
259
+ /* The right column is as wide as its widest child (usually the GPS
260
+ panel), so without this both the panel and the top-right widget
261
+ area would need explicit right edges: the panel rides the right
262
+ edge, and the top-right area must follow it — left-aligned it
263
+ would park under the panel's left half, reading as "top left". */
264
+ .dr-top-col > .dr-gps,
265
+ .dr-top-col > dr-ext-widget-area[anchor="top-right"] {
260
266
  align-self: flex-end;
261
267
  }
262
268
  /* Water-track readout: bottom-right panel — figures + the manual
@@ -524,6 +530,12 @@ class DrApp extends HTMLElement {
524
530
  openSight();
525
531
  this.sight?.seedObjectPosition(lat, lng, mode, label, tMs);
526
532
  });
533
+ // Chart view moved/zoomed: re-fetch notes for the visible area
534
+ // (work doc #30). Debounced + margin-guarded in fetchNotesInView —
535
+ // follow-mode re-centers must not turn into a request per GPS fix.
536
+ this.map?.addEventListener("dr-view-changed", (e) =>
537
+ this.fetchNotesInView(e.detail),
538
+ );
527
539
  // Hosted plotter-widget areas (work doc #27) carry no buttons: the
528
540
  // chart context menu offers placement when the pick lands on a
529
541
  // reserved area footprint. dr-app owns the hit test (the areas
@@ -771,6 +783,12 @@ class DrApp extends HTMLElement {
771
783
  * source of truth is the server's v2 resources API; the cache lets
772
784
  * saves update markers without a full refetch. */
773
785
  this.notesById = new Map();
786
+ /** Bounds the last in-view notes fetch covered (fetchNotesInView
787
+ * margin guard) — null until the first view-driven fetch.
788
+ * @type {{west: number, south: number, east: number, north: number}|null} */
789
+ this._notesBoundsFetched = null;
790
+ /** @type {ReturnType<typeof setTimeout>|null} */
791
+ this._notesViewTimer = null;
774
792
  /** Route ids with a fetch in progress — the 1 Hz course stream
775
793
  * would otherwise re-issue the request until it lands.
776
794
  * @type {Set<string>|null} */
@@ -1207,20 +1225,64 @@ class DrApp extends HTMLElement {
1207
1225
  * pattern as the active route. Notes without a position are skipped
1208
1226
  * by the viewmodel shaping (region-only notes don't plot).
1209
1227
  *
1228
+ * With a `view` the request is viewport-scoped — the Freeboard-SK
1229
+ * wire format `?position=[lon,lat]&distance=<meters>`: the view
1230
+ * center plus a covering radius to the view corner. (The Resources
1231
+ * API docs' `distance` alone is vessel-anchored, which can't express
1232
+ * "the area I'm looking at"; `position` re-anchors it to the map
1233
+ * view.) The result MERGES into the cache: a scoped response only
1234
+ * covers the fetched area, replacing the cache would drop notes
1235
+ * outside the current view. Without a view (boot, reconnect) the
1236
+ * response is the whole collection and replaces the cache. View-
1237
+ * scoped fetches matter because resource-provider backends filter
1238
+ * the collection themselves — an area-less query can legitimately
1239
+ * come back limited to the provider's default area.
1240
+ *
1241
+ * @param {{west: number, south: number, east: number,
1242
+ * north: number}} [view]
1210
1243
  * @returns {Promise<void>}
1211
1244
  */
1212
- async fetchNotes() {
1245
+ async fetchNotes(view) {
1213
1246
  try {
1214
- const res = await fetch("/signalk/v2/api/resources/notes");
1247
+ const query = view ? `?${vm.notesViewQuery(view)}` : "";
1248
+ const res = await fetch(`/signalk/v2/api/resources/notes${query}`);
1215
1249
  if (!res.ok) return;
1216
1250
  const collection = await res.json();
1217
- this.notesById = new Map(Object.entries(collection ?? {}));
1251
+ if (view) {
1252
+ for (const [id, note] of Object.entries(collection ?? {})) {
1253
+ this.notesById.set(id, note);
1254
+ }
1255
+ } else {
1256
+ this.notesById = new Map(Object.entries(collection ?? {}));
1257
+ }
1218
1258
  this.renderNotes();
1219
1259
  } catch {
1220
1260
  /* REST unavailable — retry on the next reconnect */
1221
1261
  }
1222
1262
  }
1223
1263
 
1264
+ /**
1265
+ * Schedules an in-view notes fetch after a chart move/zoom
1266
+ * (dr-view-changed from the map). Debounced, and skipped entirely
1267
+ * while the view stays within the last fetched area plus a 25%
1268
+ * margin — follow-mode re-centers track the boat without re-issuing
1269
+ * the request; panning into unseen water or zooming out re-fetches.
1270
+ *
1271
+ * @param {{west: number, south: number, east: number,
1272
+ * north: number}} bounds
1273
+ * @returns {void}
1274
+ */
1275
+ fetchNotesInView(bounds) {
1276
+ if (!bounds || !vm.notesRefetchNeeded(bounds, this._notesBoundsFetched)) {
1277
+ return;
1278
+ }
1279
+ clearTimeout(this._notesViewTimer ?? undefined);
1280
+ this._notesViewTimer = setTimeout(() => {
1281
+ this._notesBoundsFetched = bounds;
1282
+ this.fetchNotes(bounds);
1283
+ }, 500);
1284
+ }
1285
+
1224
1286
  /** @returns {void} */
1225
1287
  renderNotes() {
1226
1288
  const collection = Object.fromEntries(this.notesById);
@@ -1238,7 +1300,13 @@ class DrApp extends HTMLElement {
1238
1300
  * @returns {Promise<void>}
1239
1301
  */
1240
1302
  async saveNote(detail) {
1241
- const { id, resource } = detail;
1303
+ const { id, resource: formResource } = detail;
1304
+ // An edit rides on the stored resource: fields the form doesn't
1305
+ // own (provider metadata — properties, url, description) must
1306
+ // survive the PUT, or editing a provider-shaped note would strip
1307
+ // e.g. its synoptic-chart link.
1308
+ const stored = id ? this.notesById.get(id) : null;
1309
+ const resource = stored ? { ...stored, ...formResource } : formResource;
1242
1310
  try {
1243
1311
  const res = await fetch(
1244
1312
  id
@@ -151,6 +151,38 @@ export function occupiedCells(placements) {
151
151
  return occ;
152
152
  }
153
153
 
154
+ /**
155
+ * Do placements in this anchor mirror their column axis? Right-hand
156
+ * anchors render mirrored: logical col 0 is the viewport corner (the
157
+ * RIGHT edge), so widgets pack corner-inward — symmetric with left
158
+ * anchors, where col 0 is the left edge. Without the mirror the first
159
+ * placed widget lands in the cell farthest from the corner and the
160
+ * free space sits between it and the edge.
161
+ *
162
+ * @param {string} anchor
163
+ * @returns {boolean}
164
+ */
165
+ export function isMirroredAnchor(anchor) {
166
+ return typeof anchor === "string" && anchor.endsWith("-right");
167
+ }
168
+
169
+ /**
170
+ * CSS grid column line (1-based, physical left→right) where a
171
+ * placement's left edge renders for the given anchor. Mirrored
172
+ * (right-hand) anchors flip the column axis: a placement at logical
173
+ * column `col` spans lines `GRID_COLS - col - cols + 1 ..
174
+ * GRID_COLS - col`.
175
+ *
176
+ * @param {string} anchor
177
+ * @param {number} col - 0-based logical column (0 = the anchor corner)
178
+ * @param {number} cols - placement column span
179
+ * @returns {number} 1-based CSS grid column line
180
+ */
181
+ export function gridColLine(anchor, col, cols) {
182
+ if (isMirroredAnchor(anchor)) return GRID_COLS - col - cols + 1;
183
+ return col + 1;
184
+ }
185
+
154
186
  /**
155
187
  * First position (packing from the grid origin, row-major) where a
156
188
  * widget of this size fits without overlapping existing placements.
@@ -1032,7 +1032,11 @@ export class DrExtWidgetArea extends AreaBase {
1032
1032
  const size = core.parseSize(p.size) ?? { cols: 1, rows: 1 };
1033
1033
  cell = document.createElement("div");
1034
1034
  cell.className = "cell";
1035
- cell.style.gridArea = `${p.row + 1} / ${p.col + 1} / span ${size.rows} / span ${size.cols}`;
1035
+ // Column line via core.gridColLine: right-hand anchors mirror
1036
+ // the column axis so col 0 renders at the viewport corner.
1037
+ cell.style.gridArea =
1038
+ `${p.row + 1} / ${core.gridColLine(this.anchor, p.col, size.cols)} ` +
1039
+ `/ span ${size.rows} / span ${size.cols}`;
1036
1040
  this.grid.appendChild(cell);
1037
1041
  this.cells.set(p.instanceId, cell);
1038
1042
  }
@@ -360,14 +360,19 @@ class DrMapView extends HTMLElement {
360
360
  // Wind laylines (work doc #30) — layers-control toggle like the
361
361
  // other overlays.
362
362
  laylines: null,
363
- // Signal K notes (work doc #30) — layers-control toggle.
363
+ // Signal K notes (work doc #30) — mounted by default: notes are
364
+ // chart annotations and often navigational warnings (METAREA
365
+ // gale warnings etc.), so they must not hide behind a toggle.
366
+ // Still removable through the layers control when decluttering.
364
367
  notes: null,
365
368
  // Measure tool geometry (work doc #30) — transient, always on.
366
369
  measure: null,
367
370
  };
368
371
  /** Layer keys that mount through the layers control instead of
369
- * always-on (work doc #30). */
370
- this.toggleableLayers = new Set(["vectors", "rings", "laylines", "notes"]);
372
+ * always-on (work doc #30). Notes stay always-on by default —
373
+ * they carry navigational warnings — but remain removable
374
+ * through the control. */
375
+ this.toggleableLayers = new Set(["vectors", "rings", "laylines"]);
371
376
  this.tileLayers = {};
372
377
  this.follow = true;
373
378
  /**
@@ -424,11 +429,27 @@ class DrMapView extends HTMLElement {
424
429
  this._scaleCtrl = new NauticalScaleControl();
425
430
  this._scaleCtrl.addTo(this.map);
426
431
  // Range rings re-space with zoom (their spacing derives from the
427
- // current scale); the scale bar re-reads it too.
432
+ // current scale); the scale bar re-reads it too. dr-app listens
433
+ // for the view change to re-fetch notes for the visible area
434
+ // (work doc #30): resource-provider backends answer the bare
435
+ // collection query with a provider-shaped area, so view-scoped
436
+ // fetches (position + covering radius, Freeboard's wire format)
437
+ // are the only honest way to cover a zoomed-out view.
428
438
  this.map.on("zoomend moveend", () => {
429
439
  this._scaleCtrl.update(this.map);
430
440
  this.renderRangeRings();
431
441
  this.renderLaylines();
442
+ const b = this.map.getBounds();
443
+ this.dispatchEvent(
444
+ new CustomEvent("dr-view-changed", {
445
+ detail: {
446
+ west: b.getWest(),
447
+ south: b.getSouth(),
448
+ east: b.getEast(),
449
+ north: b.getNorth(),
450
+ },
451
+ }),
452
+ );
432
453
  });
433
454
  this.map.on("dragstart", () => {
434
455
  this.follow = false;
@@ -582,33 +603,73 @@ class DrMapView extends HTMLElement {
582
603
  addBase(c.name, layer);
583
604
  }
584
605
  // Default: the mirrored/composed vector chart when present, else
585
- // the first configured provider (list is name-sorted).
586
- const first =
606
+ // the first configured provider (list is name-sorted). A
607
+ // remembered base choice (localStorage "dr-layers") outranks
608
+ // the default when it still mounts.
609
+ const defaultBase =
587
610
  this.tileLayers.__chart_mirror__ ??
588
611
  this.tileLayers[charts[0].identifier] ??
589
612
  ordered[0];
590
- first?.addTo(this.map);
613
+ const prefs = vm.parseLayerPrefs(this._loadLayerPrefs());
614
+ const base = this.tileLayers[prefs.base ?? ""] ?? defaultBase;
615
+ base?.addTo(this.map);
591
616
  // Always mounted (even single-chart installs): the AIS traffic
592
617
  // overlay (work doc #23) needs its checkbox so the chart can be
593
618
  // de-cluttered, and the active Signal K route too (a route
594
619
  // crossing the leg being sailed shouldn't be forced on top of
595
620
  // the chartwork). Work doc #30 adds the plotter overlays:
596
621
  // predictor vectors and range rings (off by default — declutter
597
- // first, enable on demand).
622
+ // first, enable on demand) and notes (on by default — they are
623
+ // chart annotations, often navigational warnings). Keyed by
624
+ // layer id so the persisted prefs survive label renames.
625
+ const overlays = {
626
+ ais: ["AIS traffic", this.layers.ais],
627
+ route: ["Active route", this.layers.route],
628
+ vectors: ["Vectors", this.layers.vectors],
629
+ rings: ["Range rings", this.layers.rings],
630
+ laylines: ["Laylines", this.layers.laylines],
631
+ notes: ["Notes", this.layers.notes],
632
+ };
633
+ const controlOverlays = {};
634
+ for (const [label, layer] of Object.values(overlays)) {
635
+ controlOverlays[label] = layer;
636
+ }
598
637
  L.control
599
- .layers(
600
- bases,
601
- {
602
- "AIS traffic": this.layers.ais,
603
- "Active route": this.layers.route,
604
- Vectors: this.layers.vectors,
605
- "Range rings": this.layers.rings,
606
- Laylines: this.layers.laylines,
607
- Notes: this.layers.notes,
608
- },
609
- { collapsed: true, position: "bottomleft" },
610
- )
638
+ .layers(bases, controlOverlays, {
639
+ collapsed: true,
640
+ position: "bottomleft",
641
+ })
611
642
  .addTo(this.map);
643
+ // Restore remembered overlay toggles — only keys the user has
644
+ // actually flipped are applied; the rest keep their built-in
645
+ // defaults (AIS/route on, declutter toggles off).
646
+ for (const [key, [, layer]] of Object.entries(overlays)) {
647
+ const want = prefs.overlays[key];
648
+ if (want === undefined) continue;
649
+ if (want && !this.map.hasLayer(layer)) layer.addTo(this.map);
650
+ if (!want && this.map.hasLayer(layer)) this.map.removeLayer(layer);
651
+ }
652
+ // Remember the user's layer choices (base chart + overlays)
653
+ // across sessions. Restoring above re-fires these events for the
654
+ // restored layers, which just re-writes the same values.
655
+ this.map.on("baselayerchange", (e) => {
656
+ const key = Object.keys(this.tileLayers).find(
657
+ (k) => this.tileLayers[k] === e.layer,
658
+ );
659
+ if (key) {
660
+ prefs.base = key;
661
+ this._saveLayerPrefs(prefs);
662
+ }
663
+ });
664
+ this.map.on("overlayadd overlayremove", (e) => {
665
+ const key = Object.keys(overlays).find(
666
+ (k) => overlays[k][1] === e.layer,
667
+ );
668
+ if (key) {
669
+ prefs.overlays[key] = e.type === "overlayadd";
670
+ this._saveLayerPrefs(prefs);
671
+ }
672
+ });
612
673
  })
613
674
  .catch((e) => {
614
675
  // Offline stays tile-less by design, but a chart that fails to
@@ -618,6 +679,36 @@ class DrMapView extends HTMLElement {
618
679
  });
619
680
  }
620
681
 
682
+ /** localStorage key for the layers-control preferences (base chart
683
+ * + overlay toggles) — same "dr-*" convention as the sight panel's
684
+ * timezone and bearing-reference keys. */
685
+ static get LAYERS_KEY() {
686
+ return "dr-layers";
687
+ }
688
+
689
+ /**
690
+ * @returns {string|null} raw stored JSON, null when unavailable
691
+ */
692
+ _loadLayerPrefs() {
693
+ try {
694
+ return localStorage.getItem(DrMapView.LAYERS_KEY);
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * @param {{base: string|null, overlays: Record<string, boolean>}} prefs
702
+ * @returns {void}
703
+ */
704
+ _saveLayerPrefs(prefs) {
705
+ try {
706
+ localStorage.setItem(DrMapView.LAYERS_KEY, JSON.stringify(prefs));
707
+ } catch {
708
+ /* storage unavailable — keep the session values */
709
+ }
710
+ }
711
+
621
712
  /**
622
713
  * Builds the Leaflet layer for a parsed chart: `L.maplibreGL` with a
623
714
  * client-built vector style for `.pbf` charts (work doc #20), plain
@@ -871,8 +962,11 @@ class DrMapView extends HTMLElement {
871
962
  const body = document.createElement("div");
872
963
  body.className = "dr-note-body";
873
964
  // renderNoteBody escapes all input before any markup — the body
874
- // is other clients' content, synced through the server.
875
- body.innerHTML = vm.renderNoteBody(note.body, note.mimeType);
965
+ // is other clients' content, synced through the server. The text
966
+ // itself falls back to `description`: provider-shaped notes
967
+ // (metarea warnings) carry the full text there — the title is
968
+ // truncated by the source.
969
+ body.innerHTML = vm.renderNoteBody(vm.noteBodyText(note), note.mimeType);
876
970
  info.appendChild(body);
877
971
  if (note.timestamp) {
878
972
  const when = document.createElement("span");
@@ -1037,11 +1131,23 @@ class DrMapView extends HTMLElement {
1037
1131
  menu.appendChild(btn);
1038
1132
  }
1039
1133
  }
1040
- // Position the menu at the screen point of the click.
1134
+ // Position the menu at the screen point of the pick — mounted
1135
+ // first (its size isn't known until it's in the DOM), then placed
1136
+ // via the viewmodel: flipped up/left near the container edges and
1137
+ // clamped inside, so picks near the bottom or right stay on screen
1138
+ // instead of opening outside the viewport.
1041
1139
  const point = this.map.latLngToContainerPoint(latlng);
1042
- menu.style.left = `${point.x}px`;
1043
- menu.style.top = `${point.y}px`;
1044
1140
  this.mapEl.appendChild(menu);
1141
+ const place = vm.menuPlacement(
1142
+ { x: point.x, y: point.y },
1143
+ { w: menu.offsetWidth, h: menu.offsetHeight },
1144
+ {
1145
+ width: this.mapEl.clientWidth,
1146
+ height: this.mapEl.clientHeight,
1147
+ },
1148
+ );
1149
+ menu.style.left = `${place.left}px`;
1150
+ menu.style.top = `${place.top}px`;
1045
1151
  this._pickMenu = menu;
1046
1152
  // Dismiss on the next map click / pan / zoom.
1047
1153
  const dismiss = () => this.hidePickMenu();
@@ -1651,23 +1757,33 @@ class DrMapView extends HTMLElement {
1651
1757
  * @returns {object} Leaflet divIcon
1652
1758
  */
1653
1759
  _boatIcon(rotationDeg, color, opts = {}) {
1654
- const size = opts.size ?? 20;
1760
+ const size = opts.size ?? 26;
1655
1761
  const rot =
1656
1762
  rotationDeg != null
1657
1763
  ? `transform:rotate(${Math.round(rotationDeg)}deg);`
1658
1764
  : "";
1659
1765
  // Pointed hull pointing up (north): bow at top, stern at bottom,
1660
- // slightly rounded bilge — reads as a vessel at 20 px without
1766
+ // slightly rounded bilge — reads as a vessel at 26 px without
1661
1767
  // covering the chart.
1768
+ const hullPath =
1769
+ "M10 1.5 C14 5.5 16 11 16 17 L10 14.2 L4 17 C4 11 6 5.5 10 1.5 Z";
1770
+ // Cartographic casing (same trick as the ghost track): a dark
1771
+ // hull-shaped outline under the colored hull keeps the boat legible
1772
+ // over light raster charts and chart clutter alike.
1773
+ const casing =
1774
+ `<path d="${hullPath}" fill="none" ` +
1775
+ `stroke="${vm.STYLE.track.casingColor}" stroke-width="4.2" ` +
1776
+ `stroke-linejoin="round"/>`;
1777
+ // Near-solid fill and a heavy stroke — own ship must dominate the
1778
+ // marks drawn around it, not whisper from underneath them.
1662
1779
  const hull =
1663
- `<path d="M10 1.5 C14 5.5 16 11 16 17 L10 14.2 L4 17 " ` +
1664
- `C4 11 6 5.5 10 1.5 Z" fill="${color}" fill-opacity="0.35" ` +
1665
- `stroke="${color}" stroke-width="1.4" stroke-linejoin="round"/>`;
1666
- // The navigator's X — small, centered in the hull, in the marker
1667
- // white so it reads on the tinted hull over any tileset.
1780
+ `<path d="${hullPath}" fill="${color}" fill-opacity="0.92" ` +
1781
+ `stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`;
1782
+ // The navigator's X — centered in the hull, in the marker white so
1783
+ // it reads on the tinted hull over any tileset.
1668
1784
  const x = opts.x
1669
- ? `<path d="M7.6 8 L12.4 11.8 M12.4 8 L7.6 11.8" ` +
1670
- `stroke="${vm.STYLE.drMarker}" stroke-width="1.1" fill="none"/>`
1785
+ ? `<path d="M7.2 7.4 L12.8 12.2 M12.8 7.4 L7.2 12.2" ` +
1786
+ `stroke="${vm.STYLE.drMarker}" stroke-width="1.8" fill="none"/>`
1671
1787
  : "";
1672
1788
  return L.divIcon({
1673
1789
  className: "dr-ais-marker",
@@ -1676,7 +1792,7 @@ class DrMapView extends HTMLElement {
1676
1792
  html:
1677
1793
  `<div class="dr-ais-glyph" style="${rot}">` +
1678
1794
  `<svg width="${size}" height="${size}" viewBox="0 0 20 20" ` +
1679
- `xmlns="http://www.w3.org/2000/svg">${hull}${x}</svg></div>`,
1795
+ `xmlns="http://www.w3.org/2000/svg">${casing}${hull}${x}</svg></div>`,
1680
1796
  });
1681
1797
  }
1682
1798
 
@@ -135,7 +135,10 @@ class DrNotePanel extends HTMLElement {
135
135
  this.position = seed.position ?? null;
136
136
  this.editId = seed.note?.id ?? null;
137
137
  this.titleEl.value = (seed.note ? seed.note.title : "") ?? "";
138
- this.bodyEl.value = (seed.note ? seed.note.body : "") ?? "";
138
+ // The text falls back to `description` — provider-shaped notes
139
+ // (metarea warnings) carry it there, so editing doesn't start from
140
+ // an empty form.
141
+ this.bodyEl.value = seed.note ? vm.noteBodyText(seed.note) : "";
139
142
  this.heading.childNodes[0].textContent = this.editId
140
143
  ? "Edit note "
141
144
  : "New note ";
@@ -2800,6 +2800,137 @@ export function notesRenderSpecs(collection) {
2800
2800
  return specs;
2801
2801
  }
2802
2802
 
2803
+ /**
2804
+ * Formats the viewport-scoped notes query the v2 resources API accepts
2805
+ * — the same parameters Freeboard-SK sends (observed on a live server):
2806
+ * `position=[lon,lat]&distance=<meters>`. The position is the view
2807
+ * center in GeoJSON coordinate order (longitude first — Freeboard's
2808
+ * wire format, server- and provider-established), and the distance is
2809
+ * a covering radius from the center to the view corner, so the whole
2810
+ * visible area comes back. Center and corner clamp to the valid
2811
+ * lat/lon range: at low zooms Leaflet reports out-of-range edges and
2812
+ * resource providers filter on raw coordinates.
2813
+ *
2814
+ * @param {{west: number, south: number, east: number, north: number}} bounds
2815
+ * @returns {string} e.g. "position=[-175.15,-21.15]&distance=37985"
2816
+ */
2817
+ export function notesViewQuery(bounds) {
2818
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
2819
+ const lat = clamp((Number(bounds.north) + Number(bounds.south)) / 2, -90, 90);
2820
+ const lon = clamp((Number(bounds.east) + Number(bounds.west)) / 2, -180, 180);
2821
+ const cornerLat = clamp(Number(bounds.north), -90, 90);
2822
+ const cornerLon = clamp(Number(bounds.west), -180, 180);
2823
+ const radiusM = Math.max(
2824
+ 1,
2825
+ Math.ceil(distanceNm([cornerLat, cornerLon], [lat, lon]) * 1852),
2826
+ );
2827
+ return `position=[${lon},${lat}]&distance=${radiusM}`;
2828
+ }
2829
+
2830
+ /**
2831
+ * Whether a moved chart view needs a fresh in-view notes fetch: yes
2832
+ * when nothing has been fetched yet, or the view now reaches beyond
2833
+ * the last fetched area with a 25% margin (panned into unseen water,
2834
+ * or zoomed out past it). Follow-mode re-centers land inside the
2835
+ * margin, so tracking the boat doesn't re-issue the request.
2836
+ *
2837
+ * @param {{west: number, south: number, east: number, north: number}} bounds
2838
+ * @param {{west: number, south: number, east: number, north: number}|null} last
2839
+ * bounds the last fetch covered
2840
+ * @returns {boolean}
2841
+ */
2842
+ export function notesRefetchNeeded(bounds, last) {
2843
+ if (!last) return true;
2844
+ const latPad = (last.north - last.south) * 0.25;
2845
+ const lonPad = (last.east - last.west) * 0.25;
2846
+ return (
2847
+ bounds.west < last.west - lonPad ||
2848
+ bounds.east > last.east + lonPad ||
2849
+ bounds.south < last.south - latPad ||
2850
+ bounds.north > last.north + latPad
2851
+ );
2852
+ }
2853
+
2854
+ /**
2855
+ * Parses the persisted layers-control preference (localStorage JSON):
2856
+ * a base-layer key plus per-overlay enabled booleans. Storage data is
2857
+ * not gospel — malformed or wrongly-typed entries are dropped, so the
2858
+ * caller can fall back to the built-in defaults per key.
2859
+ *
2860
+ * @param {string|null} raw
2861
+ * @returns {{base: string|null, overlays: Record<string, boolean>}}
2862
+ */
2863
+ export function parseLayerPrefs(raw) {
2864
+ const prefs = { base: null, overlays: {} };
2865
+ if (!raw) return prefs;
2866
+ let data = null;
2867
+ try {
2868
+ data = JSON.parse(raw);
2869
+ } catch {
2870
+ return prefs;
2871
+ }
2872
+ if (!data || typeof data !== "object") return prefs;
2873
+ if (typeof data.base === "string" && data.base) prefs.base = data.base;
2874
+ if (data.overlays && typeof data.overlays === "object") {
2875
+ for (const [key, value] of Object.entries(data.overlays)) {
2876
+ if (typeof value === "boolean") prefs.overlays[key] = value;
2877
+ }
2878
+ }
2879
+ return prefs;
2880
+ }
2881
+
2882
+ /**
2883
+ * Placement for a floating context menu anchored at a screen point:
2884
+ * opens down-right of the anchor, flips up / left near the container
2885
+ * edges, and clamps inside with a kept-clear margin — a pick near the
2886
+ * bottom of the screen must not open the menu outside the viewport.
2887
+ * A menu bigger than the container pins to the margin.
2888
+ *
2889
+ * @param {{x: number, y: number}} point - anchor in container px
2890
+ * @param {{w: number, h: number}} size - menu size in px
2891
+ * @param {{width: number, height: number}} bounds - container size in px
2892
+ * @param {number} [margin=8] - gap kept clear at the container edges
2893
+ * @returns {{left: number, top: number}} menu position in container px
2894
+ */
2895
+ export function menuPlacement(point, size, bounds, margin = 8) {
2896
+ let left = point.x;
2897
+ let top = point.y;
2898
+ // Flip up near the bottom edge (the common miss: picks low on the
2899
+ // chart), left near the right edge.
2900
+ if (point.y + size.h + margin > bounds.height) {
2901
+ top = point.y - size.h - margin;
2902
+ }
2903
+ if (point.x + size.w + margin > bounds.width) {
2904
+ left = point.x - size.w - margin;
2905
+ }
2906
+ // Clamp inside — covers anchors near the top/left and menus taller
2907
+ // or wider than the flip space.
2908
+ const maxTop = Math.max(margin, bounds.height - size.h - margin);
2909
+ const maxLeft = Math.max(margin, bounds.width - size.w - margin);
2910
+ return {
2911
+ left: Math.min(Math.max(left, margin), maxLeft),
2912
+ top: Math.min(Math.max(top, margin), maxTop),
2913
+ };
2914
+ }
2915
+
2916
+ /**
2917
+ * The displayable text of a note resource: the v2 notes `body` when
2918
+ * present, else the `description` field — provider-shaped notes
2919
+ * (metarea warnings via signalk-passage-briefing, Freeboard-SK's
2920
+ * resource shape) carry their text there, with the title truncated by
2921
+ * the source. Empty string when neither exists.
2922
+ *
2923
+ * @param {object|null|undefined} note
2924
+ * @returns {string}
2925
+ */
2926
+ export function noteBodyText(note) {
2927
+ if (typeof note?.body === "string" && note.body) return note.body;
2928
+ if (typeof note?.description === "string" && note.description) {
2929
+ return note.description;
2930
+ }
2931
+ return "";
2932
+ }
2933
+
2803
2934
  /**
2804
2935
  * Builds the resource body for POST/PUT to `/resources/notes` from the
2805
2936
  * creation/edit form (work doc #30): title, body, mimeType and the
Binary file
@@ -224,7 +224,7 @@ test("dr-map-view: Leaflet controls relocated to the bottom-left stack", () => {
224
224
  assert.match(mapSrc, /L\.control\.zoom\(\{ position: "bottomleft" \}\)/);
225
225
  assert.match(
226
226
  mapSrc,
227
- /\{ collapsed: true, position: "bottomleft" \}/,
227
+ /collapsed: true,\s*\n\s*position: "bottomleft"/,
228
228
  "layers control bottom-left",
229
229
  );
230
230
  // The control container must clear the (non-Leaflet) re-center
@@ -101,6 +101,23 @@ test("firstFreeSlot: packs row-major, skips overlaps", () => {
101
101
  assert.equal(core.firstFreeSlot(full, "1x1"), null);
102
102
  });
103
103
 
104
+ test("gridColLine: right-hand anchors mirror the column axis", () => {
105
+ // Left anchor: col 0 is the leftmost CSS column (the corner).
106
+ assert.equal(core.isMirroredAnchor("top-left"), false);
107
+ assert.equal(core.gridColLine("top-left", 0, 1), 1);
108
+ assert.equal(core.gridColLine("top-left", 1, 1), 2);
109
+ // Right anchor: col 0 is the RIGHTMOST CSS column (the corner), so
110
+ // the first placed widget hugs the viewport edge and free space
111
+ // extends inward.
112
+ assert.equal(core.isMirroredAnchor("top-right"), true);
113
+ assert.equal(core.gridColLine("top-right", 0, 1), 2);
114
+ assert.equal(core.gridColLine("top-right", 1, 1), 1);
115
+ // Full-width spans cover both columns either way; a 1x2 in the
116
+ // corner column stays on the right edge for both rows.
117
+ assert.equal(core.gridColLine("top-right", 0, 2), 1);
118
+ assert.equal(core.gridColLine("top-right", 0, 1), core.GRID_COLS);
119
+ });
120
+
104
121
  test("layoutFromJSON: validates shape, ignores unknown anchors", () => {
105
122
  const good = core.layoutFromJSON({
106
123
  version: 1,
@@ -353,6 +353,16 @@ test("dr-app: widget areas sit below the app's own top panels", () => {
353
353
  );
354
354
  assert.ok(colRight, "top-right area follows the GPS panel in its column");
355
355
  assert.match(appSrc, /\.dr-top-col \{[^}]*flex-direction: column/s);
356
+ assert.match(
357
+ appSrc,
358
+ /\.dr-top-col > \.dr-gps,\s*\.dr-top-col > dr-ext-widget-area\[anchor="top-right"\]\s*\{\s*align-self: flex-end;/s,
359
+ "top-right area right-aligns with the GPS panel in its column",
360
+ );
361
+ assert.match(
362
+ hostSrc,
363
+ /core\.gridColLine\(this\.anchor, p\.col, size\.cols\)/,
364
+ "cell columns route through the anchor-aware mirroring helper",
365
+ );
356
366
  assert.match(
357
367
  appSrc,
358
368
  /new PlotterExtHost\(\{\s*stream: window\.drSignalkStream,\s*mount: root,/s,
@@ -57,6 +57,28 @@ test("notesRenderSpecs: positioned notes shape, position-less skipped", async ()
57
57
  assert.equal(untitled[0].title, "Note");
58
58
  });
59
59
 
60
+ test("noteBodyText: body preferred, description fallback (provider notes)", async () => {
61
+ const vm = await loadVm();
62
+ assert.equal(
63
+ vm.noteBodyText({ body: "v2 body", description: "desc" }),
64
+ "v2 body",
65
+ );
66
+ // Metarea/passage-briefing shape: text in description, title
67
+ // truncated by the source.
68
+ assert.equal(
69
+ vm.noteBodyText({
70
+ title: "IN THE AREA SOUTH OF 10S AND WEST OF 165W,…",
71
+ description:
72
+ "IN THE AREA SOUTH OF 10S AND WEST OF 165W, EXPECT SOUTHEAST WINDS 20\nTO 30 KNOTS.",
73
+ }),
74
+ "IN THE AREA SOUTH OF 10S AND WEST OF 165W, EXPECT SOUTHEAST WINDS 20\nTO 30 KNOTS.",
75
+ );
76
+ assert.equal(vm.noteBodyText({ title: "only" }), "");
77
+ assert.equal(vm.noteBodyText(null), "");
78
+ // Non-string junk is ignored, not stringified.
79
+ assert.equal(vm.noteBodyText({ body: 5, description: {} }), "");
80
+ });
81
+
60
82
  test("noteResourceFromForm: trims title, defaults, position object", async () => {
61
83
  const vm = await loadVm();
62
84
  const r = vm.noteResourceFromForm({
@@ -117,13 +139,116 @@ test("renderNoteBody: markdown gets minimal structure, still escaped", async ()
117
139
  );
118
140
  });
119
141
 
142
+ test("parseLayerPrefs: malformed storage data falls back per key", async () => {
143
+ const vm = await loadVm();
144
+ assert.deepEqual(vm.parseLayerPrefs(null), { base: null, overlays: {} });
145
+ assert.deepEqual(vm.parseLayerPrefs(""), { base: null, overlays: {} });
146
+ assert.deepEqual(vm.parseLayerPrefs("not json"), {
147
+ base: null,
148
+ overlays: {},
149
+ });
150
+ assert.deepEqual(vm.parseLayerPrefs("42"), { base: null, overlays: {} });
151
+ // Wrong-typed entries dropped individually, valid ones kept.
152
+ assert.deepEqual(
153
+ vm.parseLayerPrefs(
154
+ JSON.stringify({
155
+ base: "__chart_mirror__",
156
+ overlays: { notes: true, ais: "yes", rings: 1, vectors: false },
157
+ }),
158
+ ),
159
+ { base: "__chart_mirror__", overlays: { notes: true, vectors: false } },
160
+ );
161
+ assert.deepEqual(vm.parseLayerPrefs(JSON.stringify({ base: 7 })), {
162
+ base: null,
163
+ overlays: {},
164
+ });
165
+ });
166
+
167
+ test("layer prefs: remembered base + overlay toggles across sessions", () => {
168
+ // Storage key follows the dr-* convention; reads and writes are
169
+ // guarded (localStorage can throw in private modes).
170
+ assert.match(mapSrc, /LAYERS_KEY\(\) \{[\s\S]*?return "dr-layers";/);
171
+ assert.match(mapSrc, /_loadLayerPrefs\(\) \{/);
172
+ assert.match(mapSrc, /_saveLayerPrefs\(prefs\) \{/);
173
+ // Restore: parsed prefs select the base (only when still mounted)
174
+ // and apply per-overlay toggles without disturbing un-flipped keys.
175
+ assert.match(mapSrc, /vm\.parseLayerPrefs\(this\._loadLayerPrefs\(\)\)/);
176
+ assert.match(
177
+ mapSrc,
178
+ /this\.tileLayers\[prefs\.base \?\? ""\] \?\? defaultBase/,
179
+ );
180
+ assert.match(mapSrc, /prefs\.overlays\[key\];/);
181
+ assert.match(mapSrc, /if \(want === undefined\) continue;/);
182
+ // Persist on the layers-control's own events — base picks and
183
+ // overlay add/remove both land in storage.
184
+ assert.match(mapSrc, /this\.map\.on\("baselayerchange"/);
185
+ assert.match(mapSrc, /this\.map\.on\("overlayadd overlayremove"/);
186
+ assert.match(mapSrc, /this\._saveLayerPrefs\(prefs\)/);
187
+ });
188
+
189
+ test("notesViewQuery: Freeboard wire format position=[lon,lat]&distance=m", async () => {
190
+ const vm = await loadVm();
191
+ // A Tonga-area view: position is the view center in [lon, lat]
192
+ // (GeoJSON) order, distance a whole-meter covering radius from the
193
+ // center to the view corner — the request Freeboard-SK sends.
194
+ const view = { west: -175.4, south: -21.4, east: -174.9, north: -20.9 };
195
+ const expectedM = Math.ceil(
196
+ vm.distanceNm([-20.9, -175.4], [-21.15, -175.15]) * 1852,
197
+ );
198
+ assert.equal(
199
+ vm.notesViewQuery(view),
200
+ `position=[-175.15,-21.15]&distance=${expectedM}`,
201
+ );
202
+ // Low-zoom Leaflet views report out-of-range edges; center and
203
+ // corner clamp so the query stays in the valid lat/lon range.
204
+ const wideM = Math.ceil(vm.distanceNm([-90, -180], [0, 0]) * 1852);
205
+ assert.equal(
206
+ vm.notesViewQuery({ west: -260, south: -95, east: 260, north: 95 }),
207
+ `position=[0,0]&distance=${wideM}`,
208
+ );
209
+ });
210
+
211
+ test("notesRefetchNeeded: first fetch, margin containment, pan/zoom out", async () => {
212
+ const vm = await loadVm();
213
+ const view = { west: 24, south: 60, east: 25, north: 60.5 };
214
+ assert.equal(vm.notesRefetchNeeded(view, null), true, "nothing fetched yet");
215
+ // Inside the fetched area (or its 25% margin) — no refetch.
216
+ assert.equal(vm.notesRefetchNeeded(view, view), false);
217
+ assert.equal(
218
+ vm.notesRefetchNeeded(
219
+ { west: 24.2, south: 60.5 - 0.125 - 0.01, east: 24.8, north: 60.5 },
220
+ view,
221
+ ),
222
+ false,
223
+ "slight pan stays within the margin",
224
+ );
225
+ // Panning into unseen water, or zooming out past the fetched area.
226
+ assert.equal(
227
+ vm.notesRefetchNeeded({ ...view, east: 25.5 }, view),
228
+ true,
229
+ "pan past the margin",
230
+ );
231
+ assert.equal(
232
+ vm.notesRefetchNeeded({ west: 10, south: 59, east: 40, north: 62 }, view),
233
+ true,
234
+ "zoomed-out view exceeds the fetched area",
235
+ );
236
+ });
237
+
120
238
  test("notes wiring: v2 resources API, layers-control toggle, pick menu integration", () => {
121
- // Layer toggle.
122
- assert.match(mapSrc, /Notes: this\.layers\.notes/);
123
- assert.match(mapSrc, /"notes"/);
239
+ // Layer wiring: notes has a layers-control entry, but mounts by
240
+ // default (not in the start-detached toggleableLayers set) —
241
+ // navigational warnings must not hide behind a toggle.
242
+ assert.match(mapSrc, /notes: \["Notes", this\.layers\.notes\]/);
243
+ assert.match(
244
+ mapSrc,
245
+ /toggleableLayers = new Set\(\["vectors", "rings", "laylines"\]\)/,
246
+ );
124
247
  // Markers + detail surface.
125
248
  assert.match(mapSrc, /renderNotes\(specs, resourcesById\) \{/);
126
249
  assert.match(mapSrc, /_noteIcon\(\)/);
250
+ // Full note text: the surface shapes body → description fallback.
251
+ assert.match(mapSrc, /renderNoteBody\(vm\.noteBodyText\(note\)/);
127
252
  assert.match(mapSrc, /vm\.renderNoteBody\(/);
128
253
  assert.match(mapSrc, /"dr-note-new"/);
129
254
  assert.match(mapSrc, /"dr-note-edit"/);
@@ -136,9 +261,25 @@ test("notes wiring: v2 resources API, layers-control toggle, pick menu integrati
136
261
  appSrc,
137
262
  /renderLinkStatus\(status\) \{[\s\S]*this\.fetchNotes\(\);/,
138
263
  );
264
+ // Viewport-aware fetching: the map reports view changes, dr-app
265
+ // re-fetches the visible area with Freeboard's position+distance
266
+ // query and merges the result into the cache (providers answer the
267
+ // bare collection query with a provider-shaped area — wider views
268
+ // must fetch for themselves).
269
+ assert.match(mapSrc, /dr-view-changed/);
270
+ assert.match(mapSrc, /getBounds\(\)/);
271
+ assert.match(appSrc, /addEventListener\("dr-view-changed"/);
272
+ assert.match(appSrc, /notesViewQuery\(/);
273
+ assert.match(appSrc, /notesRefetchNeeded\(/);
274
+ assert.match(appSrc, /this\.notesById\.set\(id, note\)/);
139
275
  // Panel: quick hazard preset, error surfacing, save dispatch.
140
276
  assert.match(panelSrc, /dr-note-hazard/);
141
277
  assert.match(panelSrc, /showError\(message\)/);
142
278
  assert.match(panelSrc, /vm\.noteResourceFromForm\(/);
143
279
  assert.match(panelSrc, /No position/);
280
+ // Edit form seeds from the shaped text (description fallback) and
281
+ // dr-app's save merges the stored resource so provider metadata
282
+ // (properties, url, description) survives the PUT.
283
+ assert.match(panelSrc, /vm\.noteBodyText\(seed\.note\)/);
284
+ assert.match(appSrc, /\{ \.\.\.stored, \.\.\.formResource \}/);
144
285
  });
@@ -63,18 +63,37 @@ test("predictor vectors: separate layer, both references, tick marks", () => {
63
63
  });
64
64
 
65
65
  test("layers control: vectors and range rings are toggleable overlays", () => {
66
- const controlStart = mapSrc.indexOf(".layers(");
66
+ const controlStart = mapSrc.indexOf("const overlays = {");
67
67
  const control = mapSrc.slice(
68
68
  controlStart,
69
- mapSrc.indexOf(".addTo(this.map);", controlStart),
69
+ mapSrc.indexOf(".addTo(this.map);", mapSrc.indexOf(".layers(")),
70
70
  );
71
- assert.match(control, /Vectors: this\.layers\.vectors/);
72
- assert.match(control, /"Range rings": this\.layers\.rings/);
71
+ assert.match(control, /vectors: \["Vectors", this\.layers\.vectors\]/);
72
+ assert.match(control, /rings: \["Range rings", this\.layers\.rings\]/);
73
73
  // Toggleable layers start detached (unchecked) — not in the
74
74
  // always-on addTo loop.
75
75
  assert.match(mapSrc, /toggleableLayers\.has\(key\)/);
76
76
  });
77
77
 
78
+ test("notes: plot by default, removable through the layers control", () => {
79
+ // Notes are chart annotations and often navigational warnings —
80
+ // they must not hide behind a toggle. The layer mounts with the
81
+ // always-on group (not listed in toggleableLayers) while keeping
82
+ // its layers-control checkbox, so decluttering still works and a
83
+ // persisted "off" pick removes it.
84
+ assert.match(
85
+ mapSrc,
86
+ /toggleableLayers = new Set\(\["vectors", "rings", "laylines"\]\)/,
87
+ "notes not a start-detached toggle",
88
+ );
89
+ const controlStart = mapSrc.indexOf("const overlays = {");
90
+ const control = mapSrc.slice(
91
+ controlStart,
92
+ mapSrc.indexOf(".addTo(this.map);", mapSrc.indexOf(".layers(")),
93
+ );
94
+ assert.match(control, /notes: \["Notes", this\.layers\.notes\]/);
95
+ });
96
+
78
97
  test("nautical scale bar: custom control, ladder from the view-model", () => {
79
98
  assert.match(mapSrc, /class NauticalScaleControl extends L\.Control/);
80
99
  assert.match(mapSrc, /vm\.scaleBarSpec\(/);
@@ -136,10 +155,11 @@ test("measure tool: pick-menu start, click legs, Esc/dblclick/right-click end",
136
155
 
137
156
  test("laylines: toggleable layer, DR origin, sailing-gated, zoom-relative", () => {
138
157
  const control = mapSrc.slice(
139
- mapSrc.indexOf(".layers("),
158
+ mapSrc.indexOf("const overlays = {"),
140
159
  mapSrc.indexOf(".addTo(this.map);", mapSrc.indexOf(".layers(")),
141
160
  );
142
- assert.match(control, /Laylines: this\.layers\.laylines/);
161
+ assert.match(control, /laylines: \["Laylines", this\.layers\.laylines\]/);
162
+ assert.match(control, /L\.control\s*\n?\s*\.layers\(bases, controlOverlays/);
143
163
  assert.match(mapSrc, /"laylines"/, "toggleable registration");
144
164
  const render = mapSrc.slice(
145
165
  mapSrc.indexOf("renderLaylines(snap) {"),
@@ -155,7 +175,7 @@ test("laylines: toggleable layer, DR origin, sailing-gated, zoom-relative", () =
155
175
  assert.match(render, /metersPerPixel\(/);
156
176
  assert.match(
157
177
  mapSrc,
158
- /this\.renderLaylines\(\);\s*\n\s*\}\);/,
178
+ /zoomend moveend", \(\) => \{[\s\S]*?this\.renderLaylines\(\);/,
159
179
  "zoomend re-render",
160
180
  );
161
181
  // Tack colors follow the navigation-light convention.
@@ -939,3 +939,66 @@ test("relativeTimeText: watchkeeper-granularity ages", async () => {
939
939
  "3d ago",
940
940
  );
941
941
  });
942
+
943
+ test("menuPlacement: opens down-right, flips up at the bottom edge", async () => {
944
+ const vm = await loadVm();
945
+ // Room below: menu opens at the anchor, down-right.
946
+ assert.deepEqual(
947
+ vm.menuPlacement(
948
+ { x: 400, y: 300 },
949
+ { w: 200, h: 150 },
950
+ { width: 1000, height: 800 },
951
+ ),
952
+ { left: 400, top: 300 },
953
+ );
954
+ // The reported miss: pick near the bottom — the menu flips above the
955
+ // anchor with the kept-clear margin instead of leaving the viewport.
956
+ assert.deepEqual(
957
+ vm.menuPlacement(
958
+ { x: 400, y: 700 },
959
+ { w: 200, h: 150 },
960
+ { width: 1000, height: 800 },
961
+ ),
962
+ { left: 400, top: 542 },
963
+ );
964
+ });
965
+
966
+ test("menuPlacement: flips left at the right edge, clamps top/left", async () => {
967
+ const vm = await loadVm();
968
+ // Near the right edge: opens to the left of the anchor.
969
+ assert.deepEqual(
970
+ vm.menuPlacement(
971
+ { x: 900, y: 300 },
972
+ { w: 200, h: 150 },
973
+ { width: 1000, height: 800 },
974
+ ),
975
+ { left: 692, top: 300 },
976
+ );
977
+ // Bottom-right corner: flips both ways.
978
+ assert.deepEqual(
979
+ vm.menuPlacement(
980
+ { x: 950, y: 750 },
981
+ { w: 200, h: 150 },
982
+ { width: 1000, height: 800 },
983
+ ),
984
+ { left: 742, top: 592 },
985
+ );
986
+ // Anchor near the top/left: clamped to the margin, never negative.
987
+ assert.deepEqual(
988
+ vm.menuPlacement(
989
+ { x: 2, y: 3 },
990
+ { w: 200, h: 150 },
991
+ { width: 1000, height: 800 },
992
+ ),
993
+ { left: 8, top: 8 },
994
+ );
995
+ // A menu taller/wider than the container pins to the margin.
996
+ assert.deepEqual(
997
+ vm.menuPlacement(
998
+ { x: 500, y: 400 },
999
+ { w: 1200, h: 2000 },
1000
+ { width: 1000, height: 800 },
1001
+ ),
1002
+ { left: 8, top: 8 },
1003
+ );
1004
+ });