@meri-imperiumi/signalk-dead-reckoning 0.11.0 → 0.11.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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.2] - 2026-09-21
11
+
12
+ ### Added
13
+ - **The active Signal K route renders on the chart.** When the
14
+ own-vessel course state carries an active route
15
+ (`navigation.course.activeRoute.href` — the discovery pattern
16
+ Freeboard-SK uses), the webapp fetches the route resource from
17
+ `/signalk/v1/api/resources/routes/{id}` and draws it in the
18
+ chartplotter convention: a magenta leg line over a dark casing,
19
+ one waypoint marker per point, and the point currently being
20
+ navigated to (`pointIndex`) emphasized with a filled marker and a
21
+ "— next" tooltip. Waypoint names from the route's
22
+ `coordinatesMeta` label the markers; the layer is toggleable via
23
+ the map's layers control ("Active route"). The route clears when
24
+ the course is cancelled, re-renders from cache on waypoint
25
+ advances (no refetch), and seeds from the REST self snapshot so a
26
+ route activated before the page loaded shows immediately.
27
+
28
+ ## [0.11.1] - 2026-09-20
29
+
30
+ ### Changed
31
+ - **Widget placement moved into the chart's context menu.** The
32
+ hosted-widget areas no longer carry their own "+ EXT" buttons —
33
+ right-clicking the chart where a widget area sits (below the entry
34
+ tools or the GPS panel — the full 2×2 footprint is reserved and
35
+ stays pointer-transparent) shows the existing pick menu with an
36
+ added "Plotter widgets" entry that opens the placement picker for
37
+ that area. Only placed cells capture pointer events, so the chart
38
+ keeps dragging and right-clicking through the gaps.
39
+
10
40
  ## [0.11.0] - 2026-09-20
11
41
 
12
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/public/dr-app.js CHANGED
@@ -243,9 +243,11 @@ template.innerHTML = /* html */ `
243
243
  /* Widget areas for hosted plotter extensions (work doc #27) ride
244
244
  below the webapp's own panels in the same column — anchored,
245
245
  not at the literal viewport corner, so they can never cover
246
- the entry tools or GPS status. The area is pointer-transparent
247
- except its own interactive parts, so the chart still drags
248
- around it. */
246
+ the entry tools or GPS status. The area reserves its 2×2
247
+ footprint but is pointer-transparent except its cells, so the
248
+ chart still drags through; placement is offered by the chart
249
+ context menu (right-click on the footprint), not by any
250
+ on-chart button. */
249
251
  .dr-top-col {
250
252
  display: flex;
251
253
  flex-direction: column;
@@ -516,6 +518,24 @@ class DrApp extends HTMLElement {
516
518
  openSight();
517
519
  this.sight?.seedObjectPosition(lat, lng, mode, label, tMs);
518
520
  });
521
+ // Hosted plotter-widget areas (work doc #27) carry no buttons: the
522
+ // chart context menu offers placement when the pick lands on a
523
+ // reserved area footprint. dr-app owns the hit test (the areas
524
+ // live in this shadow root) and opens the ext host's picker.
525
+ if (this.map) {
526
+ this.map.areaAt = (x, y) => {
527
+ for (const el of root.querySelectorAll("dr-ext-widget-area")) {
528
+ const r = el.getBoundingClientRect();
529
+ if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) {
530
+ return el.getAttribute("anchor") ?? "top-left";
531
+ }
532
+ }
533
+ return null;
534
+ };
535
+ this.map.addEventListener("dr-open-ext-picker", (e) => {
536
+ this.extHost?.openPicker(e.detail.anchor);
537
+ });
538
+ }
519
539
 
520
540
  /** @type {HTMLDialogElement|null} */
521
541
  this.sightDialog = root.querySelector("#sight-dialog");
@@ -687,6 +707,22 @@ class DrApp extends HTMLElement {
687
707
  * @type {string|null}
688
708
  */
689
709
  this.shadowContext = null;
710
+ /**
711
+ * Active Signal K route (navigation.course.activeRoute), discovered
712
+ * the Freeboard-SK way: the course state carries a resource href,
713
+ * the route geometry is fetched from `/resources/routes/{id}`.
714
+ * @type {{id: string, pointIndex: number}|null}
715
+ */
716
+ this.activeRoute = null;
717
+ /** Route resources fetched this session — pointIndex deltas
718
+ * re-render from cache without a refetch. @type {Map<string, object>} */
719
+ this.routeCache = new Map();
720
+ /** Cache-buster key of the route spec currently on the map. */
721
+ this.routeRenderedKey = null;
722
+ /** Route ids with a fetch in progress — the 1 Hz course stream
723
+ * would otherwise re-issue the request until it lands.
724
+ * @type {Set<string>|null} */
725
+ this.routeFetchInFlight = null;
690
726
  this.snap = {
691
727
  drPosition: null,
692
728
  gpsPosition: null,
@@ -809,6 +845,12 @@ class DrApp extends HTMLElement {
809
845
  "environment.mode",
810
846
  "environment.current.setTrue",
811
847
  "environment.current.drift",
848
+ // Active route (Freeboard-SK's discovery pattern): the course
849
+ // provider publishes the whole activeRoute object — href, name,
850
+ // pointIndex, pointTotal — as ONE delta value at this path (not
851
+ // leaf paths; verified on the wire), pointing at
852
+ // `/resources/routes/{id}`.
853
+ "navigation.course.activeRoute",
812
854
  ]);
813
855
  // AIS targets (work doc #23): same socket, `vessels.*` scope — the
814
856
  // protocol scopes each subscribe message to its context. Static
@@ -922,12 +964,68 @@ class DrApp extends HTMLElement {
922
964
  hdop: pick("horizontalDilution"),
923
965
  });
924
966
  }
967
+ // Active route seed (Freeboard-SK pattern): the REST self snapshot
968
+ // carries the full course subtree, so a route activated before the
969
+ // page loaded shows immediately without waiting for a delta.
970
+ const active = vm.activeRouteFromCourse(nav?.navigation?.course);
971
+ if (active && active.id !== this.activeRoute?.id) {
972
+ this.activeRoute = active;
973
+ this.refreshActiveRoute();
974
+ }
925
975
  this.render();
926
976
  } catch {
927
977
  /* REST unavailable — stream will drive when it can */
928
978
  }
929
979
  }
930
980
 
981
+ /**
982
+ * Fetches and renders the currently active Signal K route. The course
983
+ * state names the route by href (Freeboard-SK's discovery pattern);
984
+ * the geometry — a GeoJSON Feature per Signal K's route resources —
985
+ * comes from `/resources/routes/{id}` and is cached for the session
986
+ * so pointIndex advances re-render without a refetch. A null route
987
+ * (course cleared, single-waypoint destination) clears the map layer.
988
+ *
989
+ * @returns {Promise<void>}
990
+ */
991
+ async refreshActiveRoute() {
992
+ const active = this.activeRoute;
993
+ if (!active) {
994
+ this.routeRenderedKey = null;
995
+ this.map?.renderRoute(null);
996
+ return;
997
+ }
998
+ let resource = this.routeCache.get(active.id);
999
+ if (!resource) {
1000
+ if (this.routeFetchInFlight?.has(active.id)) return; // already fetching
1001
+ // Signal K v2 resources API — route/waypoint resources with a
1002
+ // resource-provider backend are served under /signalk/v2 (the v1
1003
+ // path 404s; Freeboard-SK also uses v2).
1004
+ (this.routeFetchInFlight ??= new Set()).add(active.id);
1005
+ try {
1006
+ const res = await fetch(
1007
+ `/signalk/v2/api/resources/routes/${encodeURIComponent(active.id)}`,
1008
+ );
1009
+ if (!res.ok) return; // resource unavailable — next course delta retries
1010
+ resource = await res.json();
1011
+ this.routeCache.set(active.id, resource);
1012
+ } catch {
1013
+ return; // REST unavailable — next course delta retries
1014
+ } finally {
1015
+ this.routeFetchInFlight.delete(active.id);
1016
+ }
1017
+ }
1018
+ const spec = vm.routeRenderSpec(resource, active.id, active.pointIndex);
1019
+ if (!spec) return;
1020
+ // Skip re-renders for course updates that don't change anything
1021
+ // visible (the stream repeats the whole course subtree at 1 Hz).
1022
+ const key = `${active.id}:${active.pointIndex}:${spec.points.length}`;
1023
+ if (key === this.routeRenderedKey) return;
1024
+ this.routeRenderedKey = key;
1025
+ spec.labels = vm.routeWaypointLabels(resource, spec.points.length);
1026
+ this.map?.renderRoute(spec);
1027
+ }
1028
+
931
1029
  /**
932
1030
  * Seeds the AIS target store from the REST vessel snapshot (work doc
933
1031
  * #23): static data (name/mmsi) arrives here; live motion rides the
@@ -1121,6 +1219,24 @@ class DrApp extends HTMLElement {
1121
1219
  this.sight?.setMagneticVariation(vm.radToDeg(value));
1122
1220
  }
1123
1221
  break;
1222
+ case "navigation.course.activeRoute": {
1223
+ // Route activation / advancement / cancellation (null when the
1224
+ // course clears). The value carries href, pointIndex and
1225
+ // pointTotal together — one parse covers all three; only an
1226
+ // actual change triggers work (the stream repeats the subtree).
1227
+ const next = vm.activeRouteFromCourse({ activeRoute: value });
1228
+ const prev = this.activeRoute;
1229
+ if (
1230
+ (next?.id ?? null) === (prev?.id ?? null) &&
1231
+ (next?.pointIndex ?? -1) === (prev?.pointIndex ?? -1)
1232
+ ) {
1233
+ break;
1234
+ }
1235
+ this.activeRoute = next;
1236
+ this.refreshActiveRoute();
1237
+ break;
1238
+ }
1239
+ case "environment.current.setTrue":
1124
1240
  case "environment.current.setTrue":
1125
1241
  // Bus value is radians; the snap (and /status) carry degrees.
1126
1242
  if (Number.isFinite(value)) {
@@ -903,9 +903,10 @@ const AreaBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
903
903
  * webapp's own floating panels. Pure chrome: the frames are owned by
904
904
  * the manager; the visual language (translucent panel, corner
905
905
  * brackets) comes from dr-theme.js so hosted widgets sit in the same
906
- * frame as `.dr-tools`/`.dr-gps`. Empty cells stay pointer-transparent
907
- * so the chart keeps dragging through the gaps; the compact "+ EXT"
908
- * affordance opens the placement picker.
906
+ * frame as `.dr-tools`/`.dr-gps`. The element carries no buttons —
907
+ * it stays pointer-transparent everywhere except its cells, and the
908
+ * host reserves the full 2×2 footprint so the chart's context menu
909
+ * (see dr-map-view) can offer widget placement over it.
909
910
  */
910
911
  export class DrExtWidgetArea extends AreaBase {
911
912
  constructor() {
@@ -918,13 +919,19 @@ export class DrExtWidgetArea extends AreaBase {
918
919
  --theme-color: var(--color-teal, #4b8b99);
919
920
  pointer-events: none; /* chart drags through empty space */
920
921
  display: block;
922
+ /* Reserve the full 2×2 grid footprint even while empty: the
923
+ chart context menu offers widget placement here (dr-app
924
+ hit-tests this rect), so the area needs a stable location
925
+ without carrying any on-chart button of its own. */
926
+ min-width: calc(2 * ${CELL_CSS} + 6px);
927
+ min-height: calc(2 * ${CELL_CSS} + 6px);
921
928
  }
922
929
  .grid {
923
930
  display: none; /* only with placements */
924
931
  grid-template-columns: repeat(2, ${CELL_CSS});
925
932
  grid-auto-rows: ${CELL_CSS};
926
933
  gap: 6px;
927
- pointer-events: auto;
934
+ pointer-events: none; /* only the cells are interactive */
928
935
  }
929
936
  .grid.active { display: grid; }
930
937
  .cell {
@@ -935,6 +942,7 @@ export class DrExtWidgetArea extends AreaBase {
935
942
  overflow: hidden;
936
943
  min-width: 0;
937
944
  min-height: 0;
945
+ pointer-events: auto;
938
946
  }
939
947
  /* Corner brackets — same hardware-mounting language as the app
940
948
  panels, but thinner so the widget content dominates. */
@@ -963,25 +971,12 @@ export class DrExtWidgetArea extends AreaBase {
963
971
  border: 0;
964
972
  background: transparent;
965
973
  }
966
- .add {
967
- pointer-events: auto;
968
- margin-top: 6px;
969
- min-height: 32px;
970
- padding: 0 0.6rem;
971
- font-size: 0.7rem;
972
- }
973
974
  `;
974
975
  root.appendChild(style);
975
976
  const grid = document.createElement("div");
976
977
  grid.className = "grid";
977
- const add = document.createElement("button");
978
- add.className = "add";
979
- add.type = "button";
980
- add.textContent = "+ EXT";
981
- add.title = "Place a plotter-extension widget";
982
- root.append(grid, add);
978
+ root.appendChild(grid);
983
979
  this.grid = grid;
984
- this.addButton = add;
985
980
  /** @type {Map<string, HTMLDivElement>} instanceId → cell */
986
981
  this.cells = new Map();
987
982
  /** @type {Array<object>} current placements */
@@ -996,9 +991,6 @@ export class DrExtWidgetArea extends AreaBase {
996
991
  }
997
992
 
998
993
  connectedCallback() {
999
- this.addButton.addEventListener("click", () => {
1000
- this.manager?.openPicker(this.anchor);
1001
- });
1002
994
  this.render();
1003
995
  }
1004
996
 
@@ -275,6 +275,7 @@ class DrMapView extends HTMLElement {
275
275
  candidate: null,
276
276
  advancements: null,
277
277
  ais: null,
278
+ route: null,
278
279
  };
279
280
  this.tileLayers = {};
280
281
  this.follow = true;
@@ -469,11 +470,16 @@ class DrMapView extends HTMLElement {
469
470
  first?.addTo(this.map);
470
471
  // Always mounted (even single-chart installs): the AIS traffic
471
472
  // overlay (work doc #23) needs its checkbox so the chart can be
472
- // de-cluttered.
473
+ // de-cluttered, and the active Signal K route too (a route
474
+ // crossing the leg being sailed shouldn't be forced on top of
475
+ // the chartwork).
473
476
  L.control
474
477
  .layers(
475
478
  bases,
476
- { "AIS traffic": this.layers.ais },
479
+ {
480
+ "AIS traffic": this.layers.ais,
481
+ "Active route": this.layers.route,
482
+ },
477
483
  { collapsed: true, position: "bottomleft" },
478
484
  )
479
485
  .addTo(this.map);
@@ -642,6 +648,17 @@ class DrMapView extends HTMLElement {
642
648
  return best;
643
649
  }
644
650
 
651
+ /**
652
+ * Hosted-widget-area hit test, registered by dr-app: given a client
653
+ * point, returns the `<dr-ext-widget-area>` anchor it lands on (or
654
+ * null). The areas carry no buttons of their own — widget placement
655
+ * rides the pick menu when the right-click lands on a reserved area
656
+ * footprint.
657
+ *
658
+ * @type {((clientX: number, clientY: number) => (string|null))|null}
659
+ */
660
+ areaAt = null;
661
+
645
662
  /**
646
663
  * Shows a small context menu at a chart point offering to pre-seed a
647
664
  * sight form with that position as the object. Dispatches
@@ -649,7 +666,9 @@ class DrMapView extends HTMLElement {
649
666
  * label, tMs }` — `label` carries the picked symbol's charted name
650
667
  * (or an AIS target's name, work doc #23), `tMs` the pick instant the
651
668
  * position is valid for (an AIS target's predicted position is
652
- * anchored to it).
669
+ * anchored to it). When the pick lands on a hosted widget-area
670
+ * footprint (`areaAt`), the menu also offers widget placement via a
671
+ * `dr-open-ext-picker` event.
653
672
  *
654
673
  * @param {L.LatLng|[number, number]} latlng
655
674
  * @param {L.Point|null|undefined} [containerPoint]
@@ -691,6 +710,31 @@ class DrMapView extends HTMLElement {
691
710
  });
692
711
  menu.appendChild(btn);
693
712
  }
713
+ // Hosted plotter widgets (dr-app registers the hit test): a pick
714
+ // landing on a widget-area footprint offers placement there in
715
+ // the same menu — the areas carry no on-chart buttons.
716
+ if (containerPoint && this.areaAt) {
717
+ const r = this.mapEl.getBoundingClientRect();
718
+ const anchor = this.areaAt(
719
+ r.left + containerPoint.x,
720
+ r.top + containerPoint.y,
721
+ );
722
+ if (anchor) {
723
+ const btn = document.createElement("button");
724
+ btn.textContent = ` Plotter widgets (${anchor})…`;
725
+ btn.addEventListener("click", () => {
726
+ this.hidePickMenu();
727
+ this.dispatchEvent(
728
+ new CustomEvent("dr-open-ext-picker", {
729
+ bubbles: true,
730
+ composed: true,
731
+ detail: { anchor },
732
+ }),
733
+ );
734
+ });
735
+ menu.appendChild(btn);
736
+ }
737
+ }
694
738
  // Position the menu at the screen point of the click.
695
739
  const point = this.map.latLngToContainerPoint(latlng);
696
740
  menu.style.left = `${point.x}px`;
@@ -1193,6 +1237,55 @@ class DrMapView extends HTMLElement {
1193
1237
  });
1194
1238
  }
1195
1239
 
1240
+ /**
1241
+ * Renders the currently active Signal K route
1242
+ * (`navigation.course.activeRoute`, discovered the Freeboard-SK
1243
+ * way — href → `/resources/routes/{id}` fetch): a magenta leg line
1244
+ * through the route's waypoints with one marker per waypoint, the
1245
+ * point currently being navigated to emphasized. Dr-app pushes a
1246
+ * `routeRenderSpec`; null (course cleared, or a destination that
1247
+ * isn't a route) empties the layer. The whole layer is toggleable
1248
+ * via the layers control ("Active route").
1249
+ *
1250
+ * @param {{name: string, points: Array<[number, number]>,
1251
+ * targetIndex: number, labels?: Array<string>}|null} spec
1252
+ * @returns {void}
1253
+ */
1254
+ renderRoute(spec) {
1255
+ const layer = this.layers.route;
1256
+ if (!layer) return;
1257
+ layer.clearLayers();
1258
+ if (!spec || spec.points?.length < 2) return;
1259
+ // Route line over a dark casing — same cartographic trick as the
1260
+ // ghost track so the magenta stays legible over light raster tiles.
1261
+ L.polyline(spec.points, {
1262
+ color: vm.STYLE.track.casingColor,
1263
+ weight: 4.5,
1264
+ opacity: 0.85,
1265
+ interactive: false,
1266
+ }).addTo(layer);
1267
+ L.polyline(spec.points, {
1268
+ color: vm.STYLE.route,
1269
+ weight: 2.5,
1270
+ opacity: 0.95,
1271
+ })
1272
+ .bindTooltip(spec.name, { direction: "top" })
1273
+ .addTo(layer);
1274
+ for (let i = 0; i < spec.points.length; i++) {
1275
+ const target = i === spec.targetIndex;
1276
+ const label = spec.labels?.[i] ?? `WP ${i + 1}`;
1277
+ L.circleMarker(spec.points[i], {
1278
+ radius: target ? 6 : 4,
1279
+ color: vm.STYLE.route,
1280
+ fillColor: target ? vm.STYLE.route : "transparent",
1281
+ fillOpacity: target ? 0.9 : 0,
1282
+ weight: 2,
1283
+ })
1284
+ .bindTooltip(target ? `${label} — next` : label, { direction: "top" })
1285
+ .addTo(layer);
1286
+ }
1287
+ }
1288
+
1196
1289
  /**
1197
1290
  * Dispatches a map-click inspection event (work doc #13): the app
1198
1291
  * opens the detail popover for the clicked record.
@@ -47,6 +47,10 @@ export const STYLE = {
47
47
  ais: "#c77bd9",
48
48
  aisBuddy: "#6b9e78",
49
49
  aisExpiring: "#888899",
50
+ // Active Signal K route (navigation.course.activeRoute): the
51
+ // chartplotter convention — routes draw magenta, distinct from
52
+ // every DR-family color above so own track never reads as plan.
53
+ route: "#d05fa2",
50
54
  fix: {
51
55
  gps: "#6b9e78",
52
56
  celestial: "#c77b28",
@@ -2025,3 +2029,125 @@ export function aisTargetsForRender(store, nowMs, own, opts = {}) {
2025
2029
  if (own) specs.sort((a, b) => (a.rangeNm ?? 0) - (b.rangeNm ?? 0));
2026
2030
  return specs;
2027
2031
  }
2032
+
2033
+ /**
2034
+ * Extracts the resource id from a Signal K resource href —
2035
+ * `/resources/routes/{id}` → `{id}`, `/resources/waypoints/{id}` →
2036
+ * `{id}`. Freeboard-SK parses the same trailing segment off
2037
+ * `navigation.course.activeRoute.href` to look the route up.
2038
+ *
2039
+ * @param {string|null|undefined} href
2040
+ * @returns {string|null} resource id, or null when not a resource href
2041
+ */
2042
+ export function resourceIdFromHref(href) {
2043
+ if (typeof href !== "string" || href.length === 0) return null;
2044
+ const segs = href.split("/").filter((s) => s.length > 0);
2045
+ return segs.length > 0 ? segs[segs.length - 1] : null;
2046
+ }
2047
+
2048
+ /**
2049
+ * Unwraps a Signal K value node: the REST tree wraps leaves as
2050
+ * `{value, timestamp, $source}`, stream deltas carry bare values.
2051
+ * The course subtree arrives both ways (REST self-snapshot seed,
2052
+ * stream deltas), so both shapes must parse.
2053
+ *
2054
+ * @param {unknown} v
2055
+ * @returns {unknown} the bare value
2056
+ */
2057
+ function unwrapValue(v) {
2058
+ return v && typeof v === "object" && "value" in v ? v.value : v;
2059
+ }
2060
+
2061
+ /**
2062
+ * Parses the own-vessel course state (`navigation.course.*` value
2063
+ * object, or the same subtree from the REST self snapshot) into the
2064
+ * active-route state the webapp tracks: which route is the current
2065
+ * destination, and which of its points the boat is steering toward.
2066
+ * A course without an activeRoute (single-waypoint destination) or a
2067
+ * missing/null subtree yields null — the route layer clears.
2068
+ *
2069
+ * @param {object|null|undefined} course - `navigation.course` value
2070
+ * @returns {{id: string, pointIndex: number}|null}
2071
+ */
2072
+ export function activeRouteFromCourse(course) {
2073
+ // Both REST shapes occur: the whole activeRoute node wrapped
2074
+ // ({value: {href…}}) and/or individual leaves wrapped
2075
+ // ({href: {value…}}). Unwrap both.
2076
+ const active = unwrapValue(course?.activeRoute);
2077
+ const href = unwrapValue(active?.href);
2078
+ const id = resourceIdFromHref(href);
2079
+ if (!id) return null;
2080
+ const idx = unwrapValue(active?.pointIndex);
2081
+ return {
2082
+ id,
2083
+ pointIndex: Number.isFinite(idx) && idx >= 0 ? idx : -1,
2084
+ };
2085
+ }
2086
+
2087
+ /**
2088
+ * Shapes a Signal K route resource (`GET /signalk/v1/api/resources/routes/{id}`
2089
+ * — a GeoJSON Feature whose LineString geometry carries the leg
2090
+ * vertices as [lon, lat]) into the map's render spec: Leaflet-order
2091
+ * points, the route name, and the index of the point currently being
2092
+ * navigated to. Mirrors Freeboard-SK's route normalization: name
2093
+ * falls back to a short-id label when the resource carries none, and
2094
+ * a target index outside the point list degrades to "no highlight"
2095
+ * rather than pointing at a wrong waypoint.
2096
+ *
2097
+ * @param {object|null|undefined} routeResource - route resource value
2098
+ * @param {string} id - resource id (for the fallback name)
2099
+ * @param {number} [pointIndex=-1] - active point index from the course
2100
+ * @returns {{name: string, points: Array<[number, number]>,
2101
+ * targetIndex: number}|null} null when the resource has no geometry
2102
+ */
2103
+ export function routeRenderSpec(routeResource, id, pointIndex = -1) {
2104
+ const coords = routeResource?.feature?.geometry?.coordinates;
2105
+ if (!Array.isArray(coords) || coords.length < 2) return null;
2106
+ const points = [];
2107
+ for (const c of coords) {
2108
+ if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) {
2109
+ continue;
2110
+ }
2111
+ // GeoJSON order is [lon, lat]; the map wants [lat, lon].
2112
+ points.push([c[1], c[0]]);
2113
+ }
2114
+ if (points.length < 2) return null;
2115
+ const name =
2116
+ typeof routeResource?.name === "string" && routeResource.name.length > 0
2117
+ ? routeResource.name
2118
+ : typeof routeResource?.feature?.properties?.name === "string" &&
2119
+ routeResource.feature.properties.name.length > 0
2120
+ ? routeResource.feature.properties.name
2121
+ : `Route ${id.slice(-6)}`;
2122
+ const targetIndex =
2123
+ Number.isFinite(pointIndex) && pointIndex >= 0 && pointIndex < points.length
2124
+ ? pointIndex
2125
+ : -1;
2126
+ return { name, points, targetIndex };
2127
+ }
2128
+
2129
+ /**
2130
+ * Waypoint labels for the active route, from the resource's
2131
+ * coordinatesMeta (Freeboard-SK's per-point metadata — name carried
2132
+ * per waypoint). Unnamed or absent metadata falls back to the
2133
+ * traditional "WP n" numbering, 1-based.
2134
+ *
2135
+ * @param {object|null|undefined} routeResource
2136
+ * @param {number} count - number of route points
2137
+ * @returns {Array<string>} one label per point
2138
+ */
2139
+ export function routeWaypointLabels(routeResource, count) {
2140
+ const meta = Array.isArray(
2141
+ routeResource?.feature?.properties?.coordinatesMeta,
2142
+ )
2143
+ ? routeResource.feature.properties.coordinatesMeta
2144
+ : [];
2145
+ const labels = [];
2146
+ for (let i = 0; i < count; i++) {
2147
+ const name = meta[i]?.name;
2148
+ labels.push(
2149
+ typeof name === "string" && name.length > 0 ? name : `WP ${i + 1}`,
2150
+ );
2151
+ }
2152
+ return labels;
2153
+ }
@@ -21,6 +21,7 @@ const read = (f) =>
21
21
  });
22
22
  const hostSrc = read("dr-ext-host.js");
23
23
  const appSrc = read("dr-app.js");
24
+ const mapSrc = read("dr-map-view.js");
24
25
  const streamSrc = read("dr-signalk-stream.js");
25
26
 
26
27
  // ---- fakes --------------------------------------------------------------
@@ -385,6 +386,28 @@ test("dr-ext-host: widget cells reuse the theme's visual language", () => {
385
386
  assert.match(hostSrc, /:host \{[^}]*pointer-events: none/s);
386
387
  });
387
388
 
389
+ test("placement rides the chart context menu, not an on-chart button", () => {
390
+ // No “+ EXT” button stuck to the chart.
391
+ assert.doesNotMatch(hostSrc, /+ EXT/);
392
+ assert.doesNotMatch(hostSrc, /\.add \{/);
393
+ // The empty area still reserves its 2×2 footprint so there is a
394
+ // stable region for the context-menu hit test.
395
+ assert.match(hostSrc, /min-width: calc\(2 \* \$\{CELL_CSS\} \+ 6px\)/);
396
+ assert.match(hostSrc, /min-height: calc\(2 \* \$\{CELL_CSS\} \+ 6px\)/);
397
+ // Only the cells capture pointer events — gaps and empty cells
398
+ // stay clickable as chart (right-click reaches the map).
399
+ assert.match(hostSrc, /\.grid \{[\s\S]*?pointer-events: none/s);
400
+ assert.match(hostSrc, /\.cell \{[^}]*pointer-events: auto/s);
401
+ // The pick menu grows the placement entry over an area footprint.
402
+ assert.match(mapSrc, /areaAt = null;/);
403
+ assert.match(mapSrc, /this\.areaAt\(/);
404
+ assert.match(mapSrc, / Plotter widgets \(\$\{anchor\}\)…/);
405
+ assert.match(mapSrc, /dr-open-ext-picker/);
406
+ // dr-app owns the hit test and opens the host's picker.
407
+ assert.match(appSrc, /this\.map\.areaAt = /);
408
+ assert.match(appSrc, /this\.extHost\?\.openPicker\(e\.detail\.anchor\)/);
409
+ });
410
+
388
411
  test("dr-ext-host: dialogs are persistent for panels, transient for removal", () => {
389
412
  // Panel contexts own their dialog; closing must not destroy it.
390
413
  assert.match(hostSrc, /ctx\.dialog = dlg;/);
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tests for the active-route layer's pure helpers: resource-href
3
+ * parsing (Freeboard-SK's discovery pattern — the trailing segment of
4
+ * `navigation.course.activeRoute.href`), the course→active-route
5
+ * state reducer, the route-resource → map render-spec shaping
6
+ * (GeoJSON [lon, lat] → Leaflet [lat, lon], name fallbacks, target
7
+ * waypoint index), and waypoint labeling from coordinatesMeta.
8
+ * Loads the browser ESM module directly, like dr-ais.test.js.
9
+ * @file dr-route.test.js
10
+ */
11
+
12
+ const test = require("node:test");
13
+ const assert = require("node:assert/strict");
14
+
15
+ const vmPromise = import("../public/dr-viewmodel.js");
16
+
17
+ async function loadVm() {
18
+ return vmPromise;
19
+ }
20
+
21
+ const ROUTE_RESOURCE = {
22
+ name: "Summer cruise",
23
+ feature: {
24
+ type: "Feature",
25
+ geometry: {
26
+ type: "LineString",
27
+ coordinates: [
28
+ [24.9, 60.1],
29
+ [24.8, 60.2],
30
+ [24.7, 60.3],
31
+ ],
32
+ },
33
+ properties: {
34
+ coordinatesMeta: [{ name: "Helsinki" }, { name: "Porkkala" }, {}],
35
+ },
36
+ },
37
+ };
38
+
39
+ test("resourceIdFromHref extracts the trailing segment", async () => {
40
+ const vm = await loadVm();
41
+ assert.equal(
42
+ vm.resourceIdFromHref(
43
+ "/resources/routes/9d4e6a1c-1111-2222-3333-444455556666",
44
+ ),
45
+ "9d4e6a1c-1111-2222-3333-444455556666",
46
+ );
47
+ assert.equal(vm.resourceIdFromHref("resources/routes/abc"), "abc");
48
+ assert.equal(vm.resourceIdFromHref("/resources/waypoints/w1"), "w1");
49
+ });
50
+
51
+ test("resourceIdFromHref rejects non-hrefs", async () => {
52
+ const vm = await loadVm();
53
+ assert.equal(vm.resourceIdFromHref(null), null);
54
+ assert.equal(vm.resourceIdFromHref(undefined), null);
55
+ assert.equal(vm.resourceIdFromHref(""), null);
56
+ assert.equal(vm.resourceIdFromHref(42), null);
57
+ });
58
+
59
+ test("activeRouteFromCourse parses href and pointIndex", async () => {
60
+ const vm = await loadVm();
61
+ const active = vm.activeRouteFromCourse({
62
+ activeRoute: {
63
+ href: "/resources/routes/rte-1",
64
+ pointIndex: 2,
65
+ reverse: false,
66
+ },
67
+ });
68
+ assert.deepEqual(active, { id: "rte-1", pointIndex: 2 });
69
+ });
70
+
71
+ test("activeRouteFromCourse tolerates a null pointIndex", async () => {
72
+ const vm = await loadVm();
73
+ const active = vm.activeRouteFromCourse({
74
+ activeRoute: { href: "/resources/routes/rte-1", pointIndex: null },
75
+ });
76
+ assert.deepEqual(active, { id: "rte-1", pointIndex: -1 });
77
+ });
78
+
79
+ test("activeRouteFromCourse unwraps REST {value} nodes", async () => {
80
+ const vm = await loadVm();
81
+ const active = vm.activeRouteFromCourse({
82
+ activeRoute: {
83
+ href: { value: "/resources/routes/rte-1", $source: "course.provider" },
84
+ pointIndex: { value: 1 },
85
+ },
86
+ });
87
+ assert.deepEqual(active, { id: "rte-1", pointIndex: 1 });
88
+ });
89
+
90
+ test("activeRouteFromCourse parses the wire delta object (whole activeRoute as one value)", async () => {
91
+ const vm = await loadVm();
92
+ // Verbatim shape observed on the stream: the course provider emits
93
+ // the whole activeRoute object as one delta value at
94
+ // `navigation.course.activeRoute`.
95
+ const active = vm.activeRouteFromCourse({
96
+ activeRoute: {
97
+ href: "/resources/routes/7e59b216-d65f-4fca-a215-04e31de2ea1c",
98
+ name: "Orca route",
99
+ reverse: false,
100
+ pointIndex: 0,
101
+ pointTotal: 14,
102
+ },
103
+ });
104
+ assert.deepEqual(active, {
105
+ id: "7e59b216-d65f-4fca-a215-04e31de2ea1c",
106
+ pointIndex: 0,
107
+ });
108
+ // A whole-node REST wrap also parses.
109
+ const wrapped = vm.activeRouteFromCourse({
110
+ activeRoute: {
111
+ value: { href: "/resources/routes/rte-1", pointIndex: 2 },
112
+ $source: "course.provider",
113
+ },
114
+ });
115
+ assert.deepEqual(wrapped, { id: "rte-1", pointIndex: 2 });
116
+ });
117
+
118
+ test("activeRouteFromCourse returns null without a route", async () => {
119
+ const vm = await loadVm();
120
+ assert.equal(vm.activeRouteFromCourse(null), null);
121
+ assert.equal(vm.activeRouteFromCourse({}), null);
122
+ assert.equal(
123
+ vm.activeRouteFromCourse({
124
+ nextPoint: { position: { latitude: 60, longitude: 25 } },
125
+ }),
126
+ null,
127
+ );
128
+ // A single-waypoint destination is not a route.
129
+ assert.equal(
130
+ vm.activeRouteFromCourse({
131
+ nextPoint: { href: "/resources/waypoints/w1" },
132
+ }),
133
+ null,
134
+ );
135
+ });
136
+
137
+ test("routeRenderSpec swaps GeoJSON order to Leaflet [lat, lon]", async () => {
138
+ const vm = await loadVm();
139
+ const spec = vm.routeRenderSpec(ROUTE_RESOURCE, "rte-1", 1);
140
+ assert.deepEqual(spec.points, [
141
+ [60.1, 24.9],
142
+ [60.2, 24.8],
143
+ [60.3, 24.7],
144
+ ]);
145
+ assert.equal(spec.name, "Summer cruise");
146
+ assert.equal(spec.targetIndex, 1);
147
+ });
148
+
149
+ test("routeRenderSpec falls back to properties.name then short-id label", async () => {
150
+ const vm = await loadVm();
151
+ const noName = {
152
+ feature: {
153
+ geometry: {
154
+ coordinates: [
155
+ [25, 60],
156
+ [25.1, 60.1],
157
+ ],
158
+ },
159
+ properties: { name: "From properties" },
160
+ },
161
+ };
162
+ assert.equal(vm.routeRenderSpec(noName, "rte-2").name, "From properties");
163
+ const bare = {
164
+ feature: {
165
+ geometry: {
166
+ coordinates: [
167
+ [25, 60],
168
+ [25.1, 60.1],
169
+ ],
170
+ },
171
+ },
172
+ };
173
+ assert.equal(vm.routeRenderSpec(bare, "9d4e6a1c").name, "Route 4e6a1c");
174
+ });
175
+
176
+ test("routeRenderSpec clamps an out-of-range target index", async () => {
177
+ const vm = await loadVm();
178
+ assert.equal(vm.routeRenderSpec(ROUTE_RESOURCE, "rte-1", 99).targetIndex, -1);
179
+ assert.equal(vm.routeRenderSpec(ROUTE_RESOURCE, "rte-1", -5).targetIndex, -1);
180
+ assert.equal(
181
+ vm.routeRenderSpec(ROUTE_RESOURCE, "rte-1", null).targetIndex,
182
+ -1,
183
+ );
184
+ });
185
+
186
+ test("routeRenderSpec returns null without usable geometry", async () => {
187
+ const vm = await loadVm();
188
+ assert.equal(vm.routeRenderSpec(null, "rte-1"), null);
189
+ assert.equal(vm.routeRenderSpec({}, "rte-1"), null);
190
+ assert.equal(
191
+ vm.routeRenderSpec({ feature: { geometry: { coordinates: [] } } }, "rte-1"),
192
+ null,
193
+ );
194
+ // A single point is a line that isn't there yet.
195
+ assert.equal(
196
+ vm.routeRenderSpec(
197
+ { feature: { geometry: { coordinates: [[25, 60]] } } },
198
+ "rte-1",
199
+ ),
200
+ null,
201
+ );
202
+ });
203
+
204
+ test("routeWaypointLabels uses coordinatesMeta names, 1-based WP fallback", async () => {
205
+ const vm = await loadVm();
206
+ assert.deepEqual(vm.routeWaypointLabels(ROUTE_RESOURCE, 3), [
207
+ "Helsinki",
208
+ "Porkkala",
209
+ "WP 3",
210
+ ]);
211
+ assert.deepEqual(vm.routeWaypointLabels(null, 2), ["WP 1", "WP 2"]);
212
+ assert.deepEqual(vm.routeWaypointLabels({}, 1), ["WP 1"]);
213
+ });