@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 +24 -0
- package/package.json +1 -1
- package/plugin/index.js +15 -0
- package/public/dr-app.js +114 -5
- package/public/dr-map-view.js +205 -14
- package/public/dr-sight-panel.js +20 -4
- package/public/dr-signalk-stream.js +69 -13
- package/public/dr-viewmodel.js +457 -0
- package/tests/dr-ais.test.js +447 -0
- package/tests/shadow-vessel-integration.test.js +32 -0
- package/tests/vector-charts.test.js +12 -1
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
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
|
-
*
|
|
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
|
}
|
package/public/dr-map-view.js
CHANGED
|
@@ -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
|
-
|
|
219
|
-
|
|
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)
|
|
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
|
-
|
|
335
|
-
|
|
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,
|
|
476
|
-
* — `label` carries the picked symbol's charted name
|
|
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]
|
package/public/dr-sight-panel.js
CHANGED
|
@@ -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,
|
|
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
|
|
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);
|