@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 +61 -0
- package/package.json +2 -2
- package/public/dr-app.js +73 -5
- package/public/dr-ext-host-core.js +32 -0
- package/public/dr-ext-host.js +5 -1
- package/public/dr-map-view.js +151 -35
- package/public/dr-note-panel.js +4 -1
- package/public/dr-viewmodel.js +131 -0
- package/public/icon-256.png +0 -0
- package/tests/dr-app-layout.test.js +1 -1
- package/tests/dr-ext-host-core.test.js +17 -0
- package/tests/dr-ext-host.test.js +10 -0
- package/tests/dr-notes.test.js +144 -3
- package/tests/dr-plotter-map.test.js +27 -7
- package/tests/dr-viewmodel.test.js +63 -0
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/public/dr-ext-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/public/dr-map-view.js
CHANGED
|
@@ -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) —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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="
|
|
1664
|
-
`
|
|
1665
|
-
|
|
1666
|
-
//
|
|
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.
|
|
1670
|
-
`stroke="${vm.STYLE.drMarker}" stroke-width="1.
|
|
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
|
|
package/public/dr-note-panel.js
CHANGED
|
@@ -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
|
-
|
|
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 ";
|
package/public/dr-viewmodel.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/tests/dr-notes.test.js
CHANGED
|
@@ -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
|
|
122
|
-
|
|
123
|
-
|
|
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("
|
|
66
|
+
const controlStart = mapSrc.indexOf("const overlays = {");
|
|
67
67
|
const control = mapSrc.slice(
|
|
68
68
|
controlStart,
|
|
69
|
-
mapSrc.indexOf(".addTo(this.map);",
|
|
69
|
+
mapSrc.indexOf(".addTo(this.map);", mapSrc.indexOf(".layers(")),
|
|
70
70
|
);
|
|
71
|
-
assert.match(control, /
|
|
72
|
-
assert.match(control, /"Range 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("
|
|
158
|
+
mapSrc.indexOf("const overlays = {"),
|
|
140
159
|
mapSrc.indexOf(".addTo(this.map);", mapSrc.indexOf(".layers(")),
|
|
141
160
|
);
|
|
142
|
-
assert.match(control, /
|
|
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
|
-
/
|
|
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
|
+
});
|