@meri-imperiumi/signalk-dead-reckoning 0.4.0 → 0.5.0

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
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-08-28
9
+
10
+ ### Added
11
+ - **AIS targets on the DR chart (work doc #23)** — the DR webapp now
12
+ renders AIS traffic on the map and lets you take a bearing to a
13
+ vessel you can see, even in open water with no charted objects in
14
+ range. Targets arrive on the same stream under a second `vessels.*`
15
+ subscription (throttled to 2 s per path; static name/MMSI seeded from
16
+ a REST snapshot and re-seeded on reconnect), and render as a
17
+ plotter-style tri-state: active (violet arrow, green for AIS buddies,
18
+ rotated to heading/COG, with a 6-minute velocity leader), expiring
19
+ after 3 minutes without a report (grey, leader dropped, tooltip says
20
+ how old the report is), then aged out and evicted entirely after 20
21
+ minutes. Marks ride their dead-reckoned track between reports —
22
+ position is predicted forward from the last report along SOG/COG
23
+ (capped at the expiring threshold) — so right-click → "Bearing to
24
+ <name>" seeds the sight form from where the vessel actually is at
25
+ pick time, with the sight time defaulting to that same instant.
26
+ Targets are range-filtered around the own boat (default 24 nm),
27
+ own vessel and the DR shadow vessel are excluded, and an "AIS
28
+ traffic" checkbox in the layers control de-clutters the chart.
29
+ `GET /status` now reports the shadow vessel's context
30
+ (`shadowVesselContext`) so the webapp can filter it.
31
+
8
32
  ## [0.4.0] - 2026-08-28
9
33
 
10
34
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/index.js CHANGED
@@ -426,6 +426,15 @@ module.exports = (app) => {
426
426
  /** @type {{publish: Function, stop: Function}|null} shadow vessel publisher (work doc #21) */
427
427
  let shadow = null;
428
428
 
429
+ /**
430
+ * The shadow vessel's stable `vessels.<uuid>` context (work doc #21),
431
+ * when the shadow is enabled — mirrored into GET /status so the DR
432
+ * webapp's AIS layer can filter it out (it already draws the DR marker
433
+ * itself; rendering the shadow again as "traffic" would double-draw).
434
+ * @type {string|null}
435
+ */
436
+ let shadowContext = null;
437
+
429
438
  /**
430
439
  * Manual set-and-drift override (§6.2 tier 1): watchstander input,
431
440
  * honored while its TTL lasts. Set/cleared via
@@ -756,6 +765,7 @@ module.exports = (app) => {
756
765
  shadowCtx = `vessels.urn:mrn:signalk:uuid:${crypto.randomUUID()}`;
757
766
  deps.setState(db, "shadow_vessel_context", shadowCtx);
758
767
  }
768
+ shadowContext = shadowCtx;
759
769
  shadow = deps.createShadowVesselPublisher({
760
770
  app,
761
771
  context: shadowCtx,
@@ -878,6 +888,7 @@ module.exports = (app) => {
878
888
  statusTileExamplesTeardown = null;
879
889
  shadow?.stop();
880
890
  shadow = null;
891
+ shadowContext = null;
881
892
  // Hygiene: clear a live advisory so it doesn't linger after the
882
893
  // plugin stops monitoring.
883
894
  if (divergence?.active) {
@@ -2016,6 +2027,10 @@ module.exports = (app) => {
2016
2027
  // the UI's header readout can bootstrap without a delta.
2017
2028
  current: lastCurrent,
2018
2029
  manualCurrent,
2030
+ // Work doc #23: the shadow vessel's context, when enabled, so
2031
+ // the DR webapp can filter it from its AIS target layer (the
2032
+ // webapp already renders the DR position as its own marker).
2033
+ shadowVesselContext: shadowContext,
2019
2034
  });
2020
2035
  });
2021
2036
 
package/public/dr-app.js CHANGED
@@ -6,6 +6,10 @@
6
6
  * the Signal K WebSocket stream through the view-model (tracks,
7
7
  * sparkline) into `<dr-map-view>`; REST overlays (fixes, LOPs, CPLs,
8
8
  * snap vectors) refresh on a slow poll and after any confirm POST.
9
+ * AIS targets (work doc #23) ride the same stream under `vessels.*`,
10
+ * accumulate in a pure store (dr-viewmodel), and render through the
11
+ * map's AIS layer — right-clicking a target seeds a bearing from its
12
+ * predicted position.
9
13
  *
10
14
  * @file dr-app.js
11
15
  */
@@ -229,11 +233,14 @@ class DrApp extends HTMLElement {
229
233
  ?.addEventListener("click", () => this.map?.recenter());
230
234
  // Chart pick: right-click an object on the map → open the sight
231
235
  // dialog with the object position pre-seeded (and its charted name,
232
- // when the picked symbol carries one — light, seamark, peak…).
236
+ // when the picked symbol carries one — light, seamark, peak…). AIS
237
+ // picks (work doc #23) also carry tMs — the instant the seeded
238
+ // (predicted) position is valid for — so the sight time defaults to
239
+ // the moment the target was clicked.
233
240
  this.map?.addEventListener("dr-pick-position", (e) => {
234
- const { lat, lng, mode, label } = e.detail;
241
+ const { lat, lng, mode, label, tMs } = e.detail;
235
242
  openSight();
236
- this.sight?.seedObjectPosition(lat, lng, mode, label);
243
+ this.sight?.seedObjectPosition(lat, lng, mode, label, tMs);
237
244
  });
238
245
 
239
246
  /** @type {HTMLDialogElement|null} */
@@ -365,6 +372,19 @@ class DrApp extends HTMLElement {
365
372
  /** History-API backfill tracks (null until a provider answers). */
366
373
  this.gpsHistory = [];
367
374
  this.ghostHistory = [];
375
+ /**
376
+ * AIS target store (work doc #23): context → target state, fed by
377
+ * `vessels.*` deltas + the REST snapshot seed, rendered through
378
+ * `aisTargetsForRender`. Owned by dr-viewmodel's pure helpers.
379
+ * @type {Map<string, object>}
380
+ */
381
+ this.aisStore = new Map();
382
+ /**
383
+ * DR shadow vessel context (work doc #21), filtered from the AIS
384
+ * layer — the webapp already draws the DR position as its own marker.
385
+ * @type {string|null}
386
+ */
387
+ this.shadowContext = null;
368
388
  this.snap = {
369
389
  drPosition: null,
370
390
  gpsPosition: null,
@@ -387,7 +407,7 @@ class DrApp extends HTMLElement {
387
407
  this.connectStream();
388
408
  this.loadPluginConfig();
389
409
  this.bootstrapSelf();
390
- this.fetchStatus();
410
+ this.fetchStatus().then(() => this.bootstrapAis());
391
411
  this.refreshOverlays();
392
412
  this.refreshTrackHistory();
393
413
  // Slow REST refresh for persisted overlays; stream drives the live parts.
@@ -397,6 +417,10 @@ class DrApp extends HTMLElement {
397
417
  this.refreshOverlays();
398
418
  this.fetchStatus();
399
419
  }, 30000);
420
+ // AIS tick (work doc #23): predictions, expiring styling, and
421
+ // age-out must advance even when nothing else flows (moored, DR
422
+ // idle, quiet targets) — a slow dedicated pulse.
423
+ setInterval(() => this.renderAis(), 5000);
400
424
  }
401
425
 
402
426
  /**
@@ -427,6 +451,23 @@ class DrApp extends HTMLElement {
427
451
  "environment.current.setTrue",
428
452
  "environment.current.drift",
429
453
  ]);
454
+ // AIS targets (work doc #23): same socket, `vessels.*` scope — the
455
+ // protocol scopes each subscribe message to its context. Static
456
+ // data (name/mmsi) seeds from the REST snapshot (bootstrapAis);
457
+ // these paths carry the live motion. The "" root path catches
458
+ // identification from providers that publish it only as root
459
+ // values (the common AIS-plugin pattern — Freeboard-SK subscribes
460
+ // "" for the same reason); the reducer picks name/mmsi/buddy out
461
+ // of root values and ignores the rest.
462
+ stream.subscribeAis([
463
+ "navigation.position",
464
+ "navigation.courseOverGroundTrue",
465
+ "navigation.speedOverGround",
466
+ "navigation.headingTrue",
467
+ "name",
468
+ "mmsi",
469
+ "",
470
+ ]);
430
471
  stream.on((delta) => this.onDelta(delta));
431
472
  stream.onStatus((s) => this.renderLinkStatus(s));
432
473
  }
@@ -529,12 +570,69 @@ class DrApp extends HTMLElement {
529
570
  }
530
571
 
531
572
  /**
532
- * Streams a delta into the view-model and re-renders.
573
+ * Seeds the AIS target store from the REST vessel snapshot (work doc
574
+ * #23): static data (name/mmsi) arrives here; live motion rides the
575
+ * `vessels.*` deltas. Also re-run on stream reconnect — targets that
576
+ * aged out while offline come straight back with names intact.
577
+ *
578
+ * @returns {Promise<void>}
579
+ */
580
+ async bootstrapAis() {
581
+ try {
582
+ const res = await fetch("/signalk/v1/api/vessels");
583
+ if (!res.ok) return;
584
+ const body = await res.json();
585
+ vm.seedAisFromSnapshot(this.aisStore, body, {
586
+ selfContext: window.drSignalkStream?.selfContext,
587
+ shadowContext: this.shadowContext,
588
+ });
589
+ this.renderAis();
590
+ } catch {
591
+ /* REST unavailable — deltas will drive when they can */
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Renders the AIS layer (work doc #23): prunes aged-out targets, then
597
+ * pushes range-filtered marker specs to the map with the prediction
598
+ * instant. Own position prefers DR (the chart's own-boat reference);
599
+ * GPS when DR hasn't started.
600
+ *
601
+ * @returns {void}
602
+ */
603
+ renderAis() {
604
+ const nowMs = Date.now();
605
+ vm.pruneAisStore(this.aisStore, nowMs);
606
+ const own = this.snap.drPosition ?? this.snap.gpsPosition;
607
+ this.map?.renderAis(
608
+ vm.aisTargetsForRender(this.aisStore, nowMs, own),
609
+ nowMs,
610
+ );
611
+ }
612
+
613
+ /**
614
+ * Streams a delta into the view-model and re-renders. Deltas from
615
+ * other vessels (the `vessels.*` AIS subscription, work doc #23) feed
616
+ * the target store instead — own vessel (by real context or the
617
+ * literal `vessels.self`) and the DR shadow vessel are excluded.
533
618
  *
534
619
  * @param {object} delta
535
620
  * @returns {void}
536
621
  */
537
622
  onDelta(delta) {
623
+ const ctx = delta?.context;
624
+ const selfCtx = window.drSignalkStream?.selfContext;
625
+ if (
626
+ typeof ctx === "string" &&
627
+ ctx.startsWith("vessels.") &&
628
+ ctx !== "vessels.self" &&
629
+ ctx !== selfCtx &&
630
+ ctx !== this.shadowContext
631
+ ) {
632
+ vm.applyAisDelta(this.aisStore, delta);
633
+ this.renderAis();
634
+ return;
635
+ }
538
636
  for (const update of delta?.updates ?? []) {
539
637
  for (const v of update.values ?? []) {
540
638
  this.applyValue(v.path, v.value);
@@ -790,6 +888,9 @@ class DrApp extends HTMLElement {
790
888
  this.shadowRoot.querySelector("#dr-divergence").textContent =
791
889
  vm.divergenceText(this.snap.divergence);
792
890
  this.map?.render(this.snap);
891
+ // AIS ranges/leaders move with the own boat — cheap to re-shape
892
+ // alongside the snapshot (markers are reused in the map).
893
+ this.renderAis();
793
894
  }
794
895
 
795
896
  /**
@@ -806,7 +907,11 @@ class DrApp extends HTMLElement {
806
907
  const body = await res.json();
807
908
  this.snap.current = body.current ?? this.snap.current;
808
909
  this.snap.manualCurrent = body.manualCurrent ?? null;
910
+ // Shadow vessel context (work doc #21/#23): once known, the AIS
911
+ // layer filters it (the DR marker already covers that position).
912
+ this.shadowContext = body.shadowVesselContext ?? this.shadowContext;
809
913
  this.renderCurrent();
914
+ this.renderAis();
810
915
  } catch {
811
916
  /* REST unavailable — stream will drive when it can */
812
917
  }
@@ -928,6 +1033,10 @@ class DrApp extends HTMLElement {
928
1033
  } else if (status.state === "retrying") {
929
1034
  panel.classList.add("retrying");
930
1035
  text.textContent = "Signal K link lost — reconnecting…";
1036
+ } else if (status.state === "open") {
1037
+ // Re-seed AIS static data on (re)connect (work doc #23) — names
1038
+ // and MMSIs don't ride position deltas.
1039
+ this.bootstrapAis();
931
1040
  }
932
1041
  // "open" — let the DR state panel take over once data flows.
933
1042
  }
@@ -17,6 +17,12 @@
17
17
  * through MapLibre GL, vendored at ./vendor/maplibre-gl/ and mounted
18
18
  * as one Leaflet layer via the official bridge (work doc #20).
19
19
  *
20
+ * AIS targets (work doc #23) render in their own layer group from
21
+ * specs pushed by dr-app (`renderAis`, outside the snapshot path —
22
+ * targets tick independently of DR ticks). Right-clicking a target
23
+ * seeds the sight form from its predicted position, with the sight
24
+ * time anchored to the prediction instant.
25
+ *
20
26
  * @file dr-map-view.js
21
27
  */
22
28
 
@@ -114,6 +120,17 @@ class DrMapView extends HTMLElement {
114
120
  box-shadow: none !important;
115
121
  font: 11px ui-monospace, "Fira Code", monospace !important;
116
122
  }
123
+ /* AIS target glyphs (work doc #23): a rotated arrow on a bare
124
+ divIcon (Leaflet's default .leaflet-div-icon white box is
125
+ replaced by className); the inner glyph's rotation eases
126
+ between renders — the mark turns with the target. */
127
+ .dr-ais-marker {
128
+ background: transparent;
129
+ border: none;
130
+ }
131
+ .dr-ais-glyph {
132
+ transition: transform 0.5s linear;
133
+ }
117
134
  `;
118
135
  root.appendChild(style);
119
136
  // Leaflet's CSS must live INSIDE the shadow root — a document-level
@@ -172,9 +189,20 @@ class DrMapView extends HTMLElement {
172
189
  drMarker: null,
173
190
  candidate: null,
174
191
  advancements: null,
192
+ ais: null,
175
193
  };
176
194
  this.tileLayers = {};
177
195
  this.follow = true;
196
+ /**
197
+ * Rendered AIS entries by target context (work doc #23) — markers
198
+ * are reused across renderAis calls (position/style updates in
199
+ * place) so ~1 Hz re-renders don't churn DOM and flicker tooltips.
200
+ * @type {Map<string, {spec: object, marker: object, leader: object|null}>}
201
+ */
202
+ this._aisRendered = new Map();
203
+ /** Instant the rendered AIS predictions are valid for — a pick
204
+ * from a target dispatches it as the sight-time anchor. */
205
+ this._aisRenderNowMs = null;
178
206
  }
179
207
 
180
208
  connectedCallback() {
@@ -215,9 +243,20 @@ class DrMapView extends HTMLElement {
215
243
  // Chart pick: right-click / long-press opens a small context menu
216
244
  // to pre-seed a sight form with the picked object position (and the
217
245
  // object's charted name, when a symbol was hit — see pickSymbolName).
218
- this.map.on("contextmenu", (e) =>
219
- this.showPickMenu(e.latlng, e.containerPoint),
220
- );
246
+ // An AIS target under the cursor (work doc #23) wins over the chart:
247
+ // its *predicted* position seeds the bearing, with the sight time
248
+ // anchored to the prediction instant.
249
+ this.map.on("contextmenu", (e) => {
250
+ const target = this._aisTargetAt(e.containerPoint);
251
+ if (target) {
252
+ this.showPickMenu(target.position, e.containerPoint, {
253
+ label: target.label,
254
+ tMs: this._aisRenderNowMs ?? Date.now(),
255
+ });
256
+ return;
257
+ }
258
+ this.showPickMenu(e.latlng, e.containerPoint);
259
+ });
221
260
  // Hover cursor: over a charted point object you can take a bearing
222
261
  // to (light, beacon, buoy, named peak, cape, landmark), the cursor
223
262
  // becomes a crosshair signalling "right-click to take a bearing
@@ -233,7 +272,9 @@ class DrMapView extends HTMLElement {
233
272
  const pt = this._hoverPoint;
234
273
  this._hoverPoint = null;
235
274
  this.mapEl.style.cursor =
236
- pt && this.isBearingableAt(pt) ? "crosshair" : "";
275
+ pt && (this.isBearingableAt(pt) || this._aisTargetAt(pt) != null)
276
+ ? "crosshair"
277
+ : "";
237
278
  });
238
279
  });
239
280
  this.map.on("mouseout", () => {
@@ -331,9 +372,16 @@ class DrMapView extends HTMLElement {
331
372
  this.tileLayers[charts[0].identifier] ??
332
373
  ordered[0];
333
374
  first?.addTo(this.map);
334
- if (Object.keys(bases).length > 1 || configured.length === 0) {
335
- L.control.layers(bases, {}, { collapsed: true }).addTo(this.map);
336
- }
375
+ // Always mounted (even single-chart installs): the AIS traffic
376
+ // overlay (work doc #23) needs its checkbox so the chart can be
377
+ // de-cluttered.
378
+ L.control
379
+ .layers(
380
+ bases,
381
+ { "AIS traffic": this.layers.ais },
382
+ { collapsed: true },
383
+ )
384
+ .addTo(this.map);
337
385
  })
338
386
  .catch((e) => {
339
387
  // Offline stays tile-less by design, but a chart that fails to
@@ -469,20 +517,54 @@ class DrMapView extends HTMLElement {
469
517
  return vm.isBearingablePointHit(this._queryChartHits(containerPoint, 10));
470
518
  }
471
519
 
520
+ /**
521
+ * Finds the rendered AIS target nearest a Leaflet container point
522
+ * (work doc #23), within a finger-friendly catchment — targets are
523
+ * picked by proximity to the rendered glyph, not by DOM hit-testing,
524
+ * so it works identically for touch long-presses. Returns the target's
525
+ * render spec (whose `position` is the predicted position the pick
526
+ * will seed) or null.
527
+ *
528
+ * @param {L.Point|null|undefined} containerPoint
529
+ * @returns {object|null}
530
+ */
531
+ _aisTargetAt(containerPoint) {
532
+ if (!this.map || !containerPoint || this._aisRendered.size === 0) {
533
+ return null;
534
+ }
535
+ let best = null;
536
+ let bestD = 16;
537
+ for (const entry of this._aisRendered.values()) {
538
+ const p = this.map.latLngToContainerPoint(entry.spec.position);
539
+ const d = p.distanceTo(containerPoint);
540
+ if (d < bestD) {
541
+ bestD = d;
542
+ best = entry.spec;
543
+ }
544
+ }
545
+ return best;
546
+ }
547
+
472
548
  /**
473
549
  * Shows a small context menu at a chart point offering to pre-seed a
474
550
  * sight form with that position as the object. Dispatches
475
- * `dr-pick-position` (composed, bubbles) with `{ lat, lng, mode, label }`
476
- * — `label` carries the picked symbol's charted name, when one was hit.
551
+ * `dr-pick-position` (composed, bubbles) with `{ lat, lng, mode,
552
+ * label, tMs }` — `label` carries the picked symbol's charted name
553
+ * (or an AIS target's name, work doc #23), `tMs` the pick instant the
554
+ * position is valid for (an AIS target's predicted position is
555
+ * anchored to it).
477
556
  *
478
- * @param {L.LatLng} latlng
557
+ * @param {L.LatLng|[number, number]} latlng
479
558
  * @param {L.Point|null|undefined} [containerPoint]
559
+ * @param {{label?: string|null, tMs?: number}|null} [preset] - AIS
560
+ * picks resolve label/time here instead of the chart query
480
561
  * @returns {void}
481
562
  */
482
- showPickMenu(latlng, containerPoint) {
563
+ showPickMenu(latlng, containerPoint, preset = null) {
483
564
  // Remove any prior menu.
484
565
  this.hidePickMenu();
485
- const label = this.pickSymbolName(containerPoint);
566
+ const label = preset?.label ?? this.pickSymbolName(containerPoint);
567
+ const tMs = Number.isFinite(preset?.tMs) ? preset.tMs : Date.now();
486
568
  const menu = document.createElement("div");
487
569
  menu.className = "dr-pick-menu";
488
570
  const what = label ?? "this point";
@@ -501,9 +583,10 @@ class DrMapView extends HTMLElement {
501
583
  bubbles: true,
502
584
  composed: true,
503
585
  detail: {
504
- lat: latlng.lat,
505
- lng: latlng.lng,
586
+ lat: Array.isArray(latlng) ? latlng[0] : latlng.lat,
587
+ lng: Array.isArray(latlng) ? latlng[1] : latlng.lng,
506
588
  mode: it.mode,
589
+ tMs,
507
590
  ...(label ? { label } : {}),
508
591
  },
509
592
  }),
@@ -862,6 +945,114 @@ class DrMapView extends HTMLElement {
862
945
  }
863
946
  }
864
947
 
948
+ /**
949
+ * Builds the divIcon for an AIS target glyph (work doc #23): a bare
950
+ * rotated arrow (className drops Leaflet's default white box), sized
951
+ * for a finger-ish target without covering the chart. Expiring marks
952
+ * drop opacity — the plotter's "data decaying" cue.
953
+ *
954
+ * @param {object} spec - aisMarkerSpec result
955
+ * @returns {object} Leaflet divIcon
956
+ */
957
+ _aisIcon(spec) {
958
+ const rot =
959
+ spec.rotationDeg != null
960
+ ? `transform:rotate(${Math.round(spec.rotationDeg)}deg);`
961
+ : "";
962
+ const opacity = spec.expiring ? "opacity:0.55;" : "";
963
+ return L.divIcon({
964
+ className: "dr-ais-marker",
965
+ iconSize: [16, 16],
966
+ iconAnchor: [8, 8],
967
+ html: `<div class="dr-ais-glyph" style="${rot}${opacity}"><svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 L13.5 13.5 L8 10.8 L2.5 13.5 Z" fill="${spec.color}" stroke="${spec.color}" stroke-width="0.6"/></svg></div>`,
968
+ });
969
+ }
970
+
971
+ /**
972
+ * Renders the AIS target set (work doc #23). Dr-app pushes specs built
973
+ * by `aisTargetsForRender` — outside `render(snap)`, since targets tick
974
+ * independently of the DR snapshot. Markers are REUSED across calls
975
+ * (position/rotation/leader update in place) so ~1 Hz refreshes don't
976
+ * churn DOM or flicker tooltips. Targets absent from `specs` (aged
977
+ * out, range-filtered) are removed.
978
+ *
979
+ * @param {Array<object>} specs - aisMarkerSpec results
980
+ * @param {number} nowMs - instant the predicted positions are valid
981
+ * for; a right-click pick dispatches it as the sight-time anchor
982
+ * @returns {void}
983
+ */
984
+ renderAis(specs, nowMs) {
985
+ if (!this.map) return;
986
+ this._aisRenderNowMs = nowMs;
987
+ const incoming = new Map(specs.map((s) => [s.context, s]));
988
+ for (const [ctx, entry] of this._aisRendered) {
989
+ if (incoming.has(ctx)) continue;
990
+ this.layers.ais?.removeLayer(entry.marker);
991
+ if (entry.leader) this.layers.ais?.removeLayer(entry.leader);
992
+ this._aisRendered.delete(ctx);
993
+ }
994
+ for (const spec of incoming.values()) {
995
+ let entry = this._aisRendered.get(spec.context);
996
+ if (!entry) {
997
+ const icon = this._aisIcon(spec);
998
+ icon._drAis = {
999
+ rotationDeg: spec.rotationDeg,
1000
+ color: spec.color,
1001
+ expiring: spec.expiring,
1002
+ };
1003
+ const marker = L.marker(spec.position, { icon })
1004
+ .bindTooltip(spec.tooltip, { direction: "top" })
1005
+ .addTo(this.layers.ais);
1006
+ entry = { spec, marker, leader: null };
1007
+ this._aisRendered.set(spec.context, entry);
1008
+ } else {
1009
+ entry.spec = spec;
1010
+ entry.marker.setLatLng(spec.position);
1011
+ // Icon rebuild only when something visible changed — setIcon
1012
+ // recreates the DOM node, so skip it when only position moved.
1013
+ const prev = entry.marker.options?.icon ?? null;
1014
+ const changed =
1015
+ !prev ||
1016
+ prev._drAis?.rotationDeg !== spec.rotationDeg ||
1017
+ prev._drAis?.color !== spec.color ||
1018
+ prev._drAis?.expiring !== spec.expiring;
1019
+ if (changed) {
1020
+ const icon = this._aisIcon(spec);
1021
+ icon._drAis = {
1022
+ rotationDeg: spec.rotationDeg,
1023
+ color: spec.color,
1024
+ expiring: spec.expiring,
1025
+ };
1026
+ entry.marker.setIcon(icon);
1027
+ }
1028
+ entry.marker.setTooltipContent?.(spec.tooltip);
1029
+ }
1030
+ // Velocity leader: 6-minute run from the predicted position;
1031
+ // drops when the target expires (nothing trustworthy to project).
1032
+ if (spec.leader) {
1033
+ const pts = [spec.leader.from, spec.leader.to];
1034
+ if (entry.leader) {
1035
+ entry.leader.setLatLngs(pts);
1036
+ entry.leader.setStyle({
1037
+ color: spec.color,
1038
+ opacity: spec.expiring ? 0.4 : 0.8,
1039
+ });
1040
+ } else {
1041
+ entry.leader = L.polyline(pts, {
1042
+ color: spec.color,
1043
+ weight: 1,
1044
+ opacity: spec.expiring ? 0.4 : 0.8,
1045
+ dashArray: "2 4",
1046
+ interactive: false,
1047
+ }).addTo(this.layers.ais);
1048
+ }
1049
+ } else if (entry.leader) {
1050
+ this.layers.ais?.removeLayer(entry.leader);
1051
+ entry.leader = null;
1052
+ }
1053
+ }
1054
+ }
1055
+
865
1056
  /**
866
1057
  * @param {{distance_nm: number, bearing_true: number}|null} divergence
867
1058
  * @param {object|null} [sparkStats]
@@ -572,17 +572,23 @@ class DrSightPanel extends HTMLElement {
572
572
  * → "Add bearing to here" / "Distance CPL at here"). Switches to the
573
573
  * matching tab and fills the object/center coordinates (overwriting any
574
574
  * prior value — a fresh pick is intentional), plus the object *name*
575
- * when the pick resolved a charted symbol (light, seamark, peak…):
576
- * bearings are taken to identified objects, not bare coordinates.
575
+ * when the pick resolved a charted symbol (light, seamark, peak…) or an
576
+ * AIS target (work doc #23): bearings are taken to identified objects,
577
+ * not bare coordinates. `tMs` (the pick instant) pre-fills the sight
578
+ * time — for an AIS target the seeded position is its *predicted*
579
+ * position valid at exactly that instant, so position and timestamp
580
+ * stay consistent. Marked dirty so the tz toggle converts it in place
581
+ * instead of re-seeding to "now".
577
582
  * Used by dr-app when the map dispatches `dr-pick-position`.
578
583
  *
579
584
  * @param {number} lat
580
585
  * @param {number} lon
581
586
  * @param {"bearing"|"vertical"} mode
582
- * @param {string} [label] - charted name of the picked object, if any
587
+ * @param {string} [label] - charted name / AIS target name, if any
588
+ * @param {number} [tMs] - pick instant the position is valid for
583
589
  * @returns {void}
584
590
  */
585
- seedObjectPosition(lat, lon, mode, label) {
591
+ seedObjectPosition(lat, lon, mode, label, tMs = null) {
586
592
  const prefix = mode === "vertical" ? "center" : "object";
587
593
  const tab = mode === "vertical" ? "vertical" : "bearing";
588
594
  this.switchTab(tab);
@@ -594,6 +600,16 @@ class DrSightPanel extends HTMLElement {
594
600
  const nameInput = form?.querySelector('input[name="object"]');
595
601
  if (nameInput) nameInput.value = label;
596
602
  }
603
+ if (Number.isFinite(tMs)) {
604
+ const input = form?.querySelector('input[name="sight_time"]');
605
+ if (input) {
606
+ input.value = vm.isoToSightTimeInput(
607
+ new Date(tMs).toISOString(),
608
+ this.loadSightTz(),
609
+ );
610
+ input.dataset.dirty = "true";
611
+ }
612
+ }
597
613
  if (!fs) return;
598
614
  // Force-fill (a chart pick overwrites, no dirty check).
599
615
  seedCoord(fs, "lat", lat, true);