@meri-imperiumi/signalk-dead-reckoning 0.13.4 → 0.14.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 +11 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/public/dr-app.js +11 -0
- package/public/dr-ext-host-core.js +454 -6
- package/public/dr-ext-host-overlays.js +996 -0
- package/public/dr-ext-host.js +161 -14
- package/public/index.html +2 -0
- package/public/vendor/plotterext-bus/README.md +10 -6
- package/public/vendor/plotterext-bus/{chunk-RED55KML.js → chunk-UZTRWLMT.js} +29 -2
- package/public/vendor/plotterext-bus/{chunk-4W6N34SD.js → chunk-WV66EF3N.js} +74 -17
- package/public/vendor/plotterext-bus/{chunk-7XRFPDQL.js → chunk-WWNNVR3K.js} +141 -6
- package/public/vendor/plotterext-bus/extension.js +8 -2
- package/public/vendor/plotterext-bus/host.js +11 -3
- package/tests/dr-ext-host-core.test.js +6 -1
- package/tests/dr-ext-host-overlays.test.js +704 -0
- package/tests/dr-ext-host.test.js +23 -2
- package/tests/plotterext.test.js +2 -2
- package/tests/replay-passage.test.js +8 -1
- package/tools/replay-passage.js +58 -17
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **The DR webapp hosts chart layers: plotter-extension map overlays (work doc #27, chart-layer phase).** The webapp's plotter-extension host — so far widgets and panels only — gains the surface a map layer needs, with [signalk-livedepth-plugin](https://github.com/night199uk/signalk-livedepth-plugin) as the first consumer: soundings recorded by the LiveDepth plugin draw as a shaded, isobath-contoured depth surface on the DR chart, live-extended while sailing. New host capabilities: `background.iframe` (headless 1×1 sandboxed iframes running extension runtimes off-viewport, torn down with the extension), `overlays.image` (the `overlay.request` → `overlay.frame` cycle — the host sends the viewport's EPSG:3857 extent, bounds, pixel ratio clamped to the overlay's `preferredPixelRatio`, resolution, zoom and `liveInterval`; the runtime answers with a transferred `ImageBitmap` the host composites into a pane above the charts and below the chartwork/vessel, refusing `overlays.superseded` frames and pacing live ones at `overlays.throttled`; frames stay georeferenced through panning via their own extent, and world copies on either side of the antimeridian are covered), `map` (`map.getView`/`map.center`/`map.fitBounds` plus settled `map.view` events), `units` (`units.get` with the preferred depth unit, default `m`), and `buttons` (`mapToolbar` entries rendered into a stack above the bottom-left controls, toggling panels). Layer affordances: a ▤ **Plugin layers** control opens a dialog with per-layer show/hide, opacity, and the layer's settings panel (`configPanel`) — layer state persists in `dr.plotterext.overlays.v1` like the chart layers' own prefs, extensions' `state.get/set`/`state.changed` storage shared with their settings panel, and `events.publish` routing (scope `all`/`extension`). The vendored `signalk-plotterext-bus` moves 0.11.0 → 0.21.0 for the `overlay.*` and `map.view` wire surface (0.12–0.18 lack it). Nothing is needed server-side: LiveDepth registers its own `plotterExtensions` provider, and the host discovers it through the same collection polling; the same `overlays.image` surface is what a future radar-overlay provider would plug into for DR fixes.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **The GPS/override bar no longer sits in the middle of a phone screen** (`public/dr-ext-host.js`): on the ≤600 px phone layout the top band stacks vertically, and every `<dr-ext-widget-area>` reserves its full 2×2 widget footprint even while empty — so the empty top-left area inflated the tools column to ~323 px and pushed the GPS status + Engage OVERRIDE panel down to y≈337 of an 844 px viewport, mid-screen. The reservation exists only for the chart context-menu placement hit-test, so it now collapses inside the area's own phone media query; a placed grid still sizes itself through its rows, and placement simply isn't offered on an empty phone area. Phone top band drops from ~656 px to ~260 px with the GPS bar back at the top. `doc/dr-mobile.png` regenerated with the fixed layout.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Passage replay gains a blind variant — the GPS-loss backtest the tier-2 circularity demanded** (`tools/replay-passage.js`): the derived tier-2 current is fitted to the very GPS track a replay scores against, so "derived beats everything" over a fully-GPS-fed passage is a tautology, not a result. The new `--blind-from ISO` variant simulates the actual use case — GPS dies mid-passage: fixes stop feeding the derived-current state at the cutoff while snapshots keep being taken, so the shadow boat navigates on the carried EWMA current decaying at its 24 h time constant and lapsing to tier 5 after the TTL, with divergence still scored against the hidden GPS track. First blind run on the Niue→Vava'u passage (2026-09-11→14, 253 nm, GPS lost at hour 24 of 60): DR stayed within ~10 nm of truth through the entire 24 h carry window, then degraded at ~1 nm/h once the current support vanished — 22.5 nm final vs 10.0 nm with GPS live and 38.2 nm cold. Also fixed on the way: the derived-current provider returned no `tier` field, so the §6.2 training gate read every tick as unresolved and the learning variant silently never trained (0 matrix bins, learn ≡ derived); it now stamps `tier: 2`, the leeway-synthetic test's threshold is recalibrated to the post-gate semantics (the old bound was met by the current, not by learning), and the `scudLearn` variant is vestigial by design — the gate deliberately blocks training on model currents.
|
|
20
|
+
|
|
10
21
|
## [0.13.4] - 2026-10-16
|
|
11
22
|
|
|
12
23
|
### Added
|
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ An offline-first dead reckoning and sensor fusion engine for Signal K that maint
|
|
|
6
6
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
|
-
Planning for life without GPS is no longer hypothetical: the UK Maritime and Coastguard Agency's Guidance Note 719 ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct
|
|
9
|
+
Planning for life without GPS is no longer hypothetical: the UK Maritime and Coastguard Agency's [Guidance Note 719](https://www.gov.uk/government/publications/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments) ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct 2026) reports a marked increase in GNSS jamming and spoofing worldwide and asks mariners to review contingency arrangements, maintain resilient navigation practices, and practice navigating without satellite positioning — naming dead reckoning, visual bearings, and celestial fixing as valid methods. This plugin implements a contingency for Signal K vessels.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
13
|
* This plugin installed and configured
|
|
14
14
|
* Some chart source available to Signal K
|
|
15
15
|
* Hand bearing compass and/or sextant
|
|
16
|
+
* A heading source independent of GNSS — a fluxgate or inertial compass feeding `navigation.headingMagnetic`, not a satellite (GNSS) compass. A satellite compass shares the jamming and spoofing failure mode of the position fix, so a vessel whose only heading source is satellite-derived cannot dead-reckon through GNSS denial. Be careful checking this: on N2K networks a single physical device often provides both compass and position data, and display list entries do not necessarily reflect that (a device may publish both PGN 127257 heading and PGN 129029 position under one source)
|
|
16
17
|
|
|
17
18
|
## Getting started
|
|
18
19
|
|
package/package.json
CHANGED
package/public/dr-app.js
CHANGED
|
@@ -903,7 +903,18 @@ class DrApp extends HTMLElement {
|
|
|
903
903
|
this.extHost = new PlotterExtHost({
|
|
904
904
|
stream: window.drSignalkStream,
|
|
905
905
|
mount: root,
|
|
906
|
+
// The chart, once it exists — map overlays composite onto it and
|
|
907
|
+
// the map capability reads/drives its viewport.
|
|
908
|
+
getMap: () => this.map?.map ?? null,
|
|
906
909
|
});
|
|
910
|
+
// The host hooks the Leaflet events itself, but discovery can land
|
|
911
|
+
// before the chart exists — forward the element's own view-change
|
|
912
|
+
// event and kick once so the first overlay frames are requested
|
|
913
|
+
// even without a user pan.
|
|
914
|
+
this.map?.addEventListener("dr-view-changed", () =>
|
|
915
|
+
this.extHost.viewChanged(),
|
|
916
|
+
);
|
|
917
|
+
setTimeout(() => this.extHost.viewChanged(), 250);
|
|
907
918
|
for (const el of root.querySelectorAll("dr-ext-widget-area")) {
|
|
908
919
|
this.extHost.attachArea(el);
|
|
909
920
|
}
|
|
@@ -20,17 +20,32 @@ export const AREA_ANCHORS = ["top-left", "top-right"];
|
|
|
20
20
|
export const GRID_COLS = 2;
|
|
21
21
|
export const GRID_ROWS = 2;
|
|
22
22
|
|
|
23
|
-
/** Capabilities this host advertises (work doc #27 v1 scope
|
|
23
|
+
/** Capabilities this host advertises (work doc #27 v1 scope, plus the
|
|
24
|
+
* chart-layer phase): widgets and panels, headless background runtimes,
|
|
25
|
+
* image map overlays, the map viewport, and the user's units. `buttons`
|
|
26
|
+
* covers mapToolbar entries; `signalk.put` is offered but only works
|
|
27
|
+
* for write-capable sessions. */
|
|
24
28
|
export const HOST_CAPABILITIES = [
|
|
25
29
|
"widgets",
|
|
26
30
|
"panels.iframe",
|
|
27
31
|
"signalk.stream",
|
|
28
32
|
"signalk.put",
|
|
33
|
+
"background.iframe",
|
|
34
|
+
"overlays.image",
|
|
35
|
+
"map",
|
|
36
|
+
"units",
|
|
37
|
+
"buttons",
|
|
29
38
|
];
|
|
30
39
|
|
|
31
40
|
/** API major version this host implements. */
|
|
32
41
|
export const API_VERSION = "1";
|
|
33
42
|
|
|
43
|
+
/** Baseline sandbox for every extension iframe: fault containment,
|
|
44
|
+
* not an adversarial boundary (install time is the trust decision).
|
|
45
|
+
* Shared by the widget/panel contexts and the headless background
|
|
46
|
+
* runtimes — one definition so they cannot drift. */
|
|
47
|
+
export const IFRAME_SANDBOX = "allow-scripts allow-same-origin allow-forms";
|
|
48
|
+
|
|
34
49
|
/**
|
|
35
50
|
* Parses a widget `size` ("<cols>x<rows>") into cell spans.
|
|
36
51
|
*
|
|
@@ -66,16 +81,75 @@ export function contributionUsable(entry, kind) {
|
|
|
66
81
|
return true;
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Is a `background[]` runtime entry usable? A headless iframe needs an
|
|
86
|
+
* id and a page to load; everything else is the runtime's business.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} entry - manifest background entry
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function backgroundUsable(entry) {
|
|
92
|
+
return (
|
|
93
|
+
!!entry &&
|
|
94
|
+
typeof entry === "object" &&
|
|
95
|
+
typeof entry.id === "string" &&
|
|
96
|
+
entry.id !== "" &&
|
|
97
|
+
typeof entry.url === "string" &&
|
|
98
|
+
entry.url !== ""
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Overlay bands the host composites (above the charts, in manifest
|
|
103
|
+
* order; `aboveResources` sits above those). */
|
|
104
|
+
export const OVERLAY_BANDS = ["aboveCharts", "aboveResources"];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is an `overlays[]` entry usable? Only `image` overlays are hosted —
|
|
108
|
+
* the host composites bitmaps the extension's runtime draws; `data`
|
|
109
|
+
* overlays (host-drawn MapLibre styles) are a separate capability this
|
|
110
|
+
* host does not advertise, so they never reach the filter.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} entry - manifest overlay entry
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function overlayUsable(entry) {
|
|
116
|
+
if (!entry || typeof entry !== "object") return false;
|
|
117
|
+
if (typeof entry.id !== "string" || entry.id === "") return false;
|
|
118
|
+
// Type is optional in the manifest (image was the only kind when the
|
|
119
|
+
// field was introduced); an explicit non-image type is not hosted.
|
|
120
|
+
if (entry.type !== undefined && entry.type !== "image") return false;
|
|
121
|
+
if (entry.band !== undefined && !OVERLAY_BANDS.includes(entry.band)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Is a `buttons[]` entry usable? This host renders mapToolbar buttons
|
|
129
|
+
* only; entries for slots it does not have are ignored per the spec.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} entry - manifest button entry
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function buttonUsable(entry) {
|
|
135
|
+
if (!entry || typeof entry !== "object") return false;
|
|
136
|
+
if (typeof entry.id !== "string" || entry.id === "") return false;
|
|
137
|
+
if (entry.slot !== undefined && entry.slot !== "mapToolbar") return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
69
141
|
/**
|
|
70
142
|
* Filters a discovered `plotterExtensions` collection down to the
|
|
71
143
|
* extensions this host can offer: manifest `apiVersion` must match,
|
|
72
144
|
* every `requires` capability must be advertised, and contributions
|
|
73
|
-
* are narrowed to usable widget/panel
|
|
74
|
-
* ignored per the spec).
|
|
145
|
+
* are narrowed to usable widget/panel/overlay/background/button
|
|
146
|
+
* entries (unknown sections are ignored per the spec).
|
|
75
147
|
*
|
|
76
148
|
* @param {object} collection - `{ [extId]: manifest }`
|
|
77
149
|
* @param {string[]} capabilities
|
|
78
|
-
* @returns {Map<string, {manifest: object, widgets: object[],
|
|
150
|
+
* @returns {Map<string, {manifest: object, widgets: object[],
|
|
151
|
+
* panels: object[], overlays: object[], background: object[],
|
|
152
|
+
* buttons: object[]}>}
|
|
79
153
|
*/
|
|
80
154
|
export function compatibleExtensions(collection, capabilities) {
|
|
81
155
|
const out = new Map();
|
|
@@ -91,8 +165,32 @@ export function compatibleExtensions(collection, capabilities) {
|
|
|
91
165
|
const panels = (
|
|
92
166
|
Array.isArray(manifest.panels) ? manifest.panels : []
|
|
93
167
|
).filter((p) => contributionUsable(p, "panels"));
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
const overlays = (
|
|
169
|
+
Array.isArray(manifest.overlays) ? manifest.overlays : []
|
|
170
|
+
).filter(overlayUsable);
|
|
171
|
+
const background = (
|
|
172
|
+
Array.isArray(manifest.background) ? manifest.background : []
|
|
173
|
+
).filter(backgroundUsable);
|
|
174
|
+
const buttons = (
|
|
175
|
+
Array.isArray(manifest.buttons) ? manifest.buttons : []
|
|
176
|
+
).filter(buttonUsable);
|
|
177
|
+
if (
|
|
178
|
+
widgets.length === 0 &&
|
|
179
|
+
panels.length === 0 &&
|
|
180
|
+
overlays.length === 0 &&
|
|
181
|
+
background.length === 0 &&
|
|
182
|
+
buttons.length === 0
|
|
183
|
+
) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
out.set(extId, {
|
|
187
|
+
manifest,
|
|
188
|
+
widgets,
|
|
189
|
+
panels,
|
|
190
|
+
overlays,
|
|
191
|
+
background,
|
|
192
|
+
buttons,
|
|
193
|
+
});
|
|
96
194
|
}
|
|
97
195
|
return out;
|
|
98
196
|
}
|
|
@@ -293,6 +391,153 @@ export function layoutKey(prefix = "dr.plotterext") {
|
|
|
293
391
|
return `${prefix}.layout.v1`;
|
|
294
392
|
}
|
|
295
393
|
|
|
394
|
+
/**
|
|
395
|
+
* localStorage key for persisted overlay layer state (visibility,
|
|
396
|
+
* opacity) — saved like the chart layers' own prefs (work doc #27,
|
|
397
|
+
* chart-layer phase).
|
|
398
|
+
*
|
|
399
|
+
* @param {string} [prefix]
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
export function overlayStateKey(prefix = "dr.plotterext") {
|
|
403
|
+
return `${prefix}.overlays.v1`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Overlay state defaults per the manifest entry. */
|
|
407
|
+
export const OVERLAY_DEFAULTS = {
|
|
408
|
+
band: "aboveCharts",
|
|
409
|
+
visible: true,
|
|
410
|
+
opacity: 0.85,
|
|
411
|
+
minZoom: 0,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Merges a manifest overlay entry with the host defaults.
|
|
416
|
+
*
|
|
417
|
+
* @param {object} entry - validated overlay manifest entry
|
|
418
|
+
* @returns {{id: string, title: string, band: string, visible: boolean,
|
|
419
|
+
* opacity: number, minZoom: number, configPanel: string|null,
|
|
420
|
+
* preferredPixelRatio: number|null, runtime: string|null}}
|
|
421
|
+
*/
|
|
422
|
+
export function overlaySpec(entry) {
|
|
423
|
+
const opacity =
|
|
424
|
+
typeof entry.opacity === "number" &&
|
|
425
|
+
entry.opacity >= 0 &&
|
|
426
|
+
entry.opacity <= 1
|
|
427
|
+
? entry.opacity
|
|
428
|
+
: OVERLAY_DEFAULTS.opacity;
|
|
429
|
+
return {
|
|
430
|
+
id: entry.id,
|
|
431
|
+
title: entry.title ?? entry.id,
|
|
432
|
+
band: entry.band ?? OVERLAY_DEFAULTS.band,
|
|
433
|
+
visible: entry.visible ?? OVERLAY_DEFAULTS.visible,
|
|
434
|
+
opacity,
|
|
435
|
+
minZoom:
|
|
436
|
+
typeof entry.minZoom === "number"
|
|
437
|
+
? entry.minZoom
|
|
438
|
+
: OVERLAY_DEFAULTS.minZoom,
|
|
439
|
+
configPanel:
|
|
440
|
+
typeof entry.configPanel === "string" ? entry.configPanel : null,
|
|
441
|
+
preferredPixelRatio:
|
|
442
|
+
typeof entry.preferredPixelRatio === "number"
|
|
443
|
+
? entry.preferredPixelRatio
|
|
444
|
+
: null,
|
|
445
|
+
// The background runtime that draws this overlay's frames.
|
|
446
|
+
runtime: typeof entry.runtime === "string" ? entry.runtime : null,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Overlay layer state, persisted per extension+overlay the way the
|
|
452
|
+
* chart layers' toggles are. Storage is injected so Node tests can
|
|
453
|
+
* pass a fake; the store holds only user overrides — entries missing
|
|
454
|
+
* from storage fall back to the manifest defaults.
|
|
455
|
+
*/
|
|
456
|
+
export class OverlayStateStore {
|
|
457
|
+
/**
|
|
458
|
+
* @param {{getItem: (k: string) => string|null,
|
|
459
|
+
* setItem: (k: string, v: string) => void}} storage
|
|
460
|
+
*/
|
|
461
|
+
constructor(storage) {
|
|
462
|
+
this.storage = storage;
|
|
463
|
+
this.data = this.load();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @returns {Record<string, Record<string, {visible?: boolean, opacity?: number}>>}
|
|
468
|
+
*/
|
|
469
|
+
load() {
|
|
470
|
+
try {
|
|
471
|
+
const raw = this.storage.getItem(overlayStateKey());
|
|
472
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
473
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
474
|
+
} catch {
|
|
475
|
+
return {};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @returns {void}
|
|
481
|
+
*/
|
|
482
|
+
save() {
|
|
483
|
+
this.storage.setItem(overlayStateKey(), JSON.stringify(this.data));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Effective state for an overlay: manifest defaults merged with the
|
|
488
|
+
* user's persisted overrides.
|
|
489
|
+
*
|
|
490
|
+
* @param {object} spec - overlaySpec() output (manifest defaults)
|
|
491
|
+
* @param {string} extId
|
|
492
|
+
* @returns {{visible: boolean, opacity: number}}
|
|
493
|
+
*/
|
|
494
|
+
effective(spec, extId) {
|
|
495
|
+
const saved = this.data[extId]?.[spec.id] ?? {};
|
|
496
|
+
return {
|
|
497
|
+
visible: saved.visible ?? spec.visible,
|
|
498
|
+
opacity:
|
|
499
|
+
typeof saved.opacity === "number" &&
|
|
500
|
+
saved.opacity >= 0 &&
|
|
501
|
+
saved.opacity <= 1
|
|
502
|
+
? saved.opacity
|
|
503
|
+
: spec.opacity,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Persists a user override.
|
|
509
|
+
*
|
|
510
|
+
* @param {string} extId
|
|
511
|
+
* @param {string} overlayId
|
|
512
|
+
* @param {{visible?: boolean, opacity?: number}} patch
|
|
513
|
+
* @returns {{visible: boolean, opacity: number}} the new effective
|
|
514
|
+
* state (against bare defaults — callers merge with their spec)
|
|
515
|
+
*/
|
|
516
|
+
set(extId, overlayId, patch) {
|
|
517
|
+
if (!this.data[extId]) this.data[extId] = {};
|
|
518
|
+
const ext = this.data[extId];
|
|
519
|
+
const cur = ext[overlayId] ?? {};
|
|
520
|
+
if (patch.visible !== undefined) cur.visible = patch.visible;
|
|
521
|
+
if (patch.opacity !== undefined) cur.opacity = patch.opacity;
|
|
522
|
+
ext[overlayId] = cur;
|
|
523
|
+
this.save();
|
|
524
|
+
return { visible: cur.visible ?? true, opacity: cur.opacity ?? 0.85 };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Drops an extension's overrides (extension vanished — the layer UI
|
|
529
|
+
* goes with it; a returning extension starts from its defaults).
|
|
530
|
+
*
|
|
531
|
+
* @param {string} extId
|
|
532
|
+
* @returns {void}
|
|
533
|
+
*/
|
|
534
|
+
dropExtension(extId) {
|
|
535
|
+
if (!(extId in this.data)) return;
|
|
536
|
+
delete this.data[extId];
|
|
537
|
+
this.save();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
296
541
|
/**
|
|
297
542
|
* localStorage key for an extension's state scope. Extension scope is
|
|
298
543
|
* shared across contexts; instance scope is keyed by the placement's
|
|
@@ -502,3 +747,206 @@ export function newInstanceId() {
|
|
|
502
747
|
const hex = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
503
748
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
504
749
|
}
|
|
750
|
+
|
|
751
|
+
// ---- map overlays: geometry and frame cycle ----------------------------
|
|
752
|
+
|
|
753
|
+
/** Web Mercator world width, in metres (equator = one world copy). */
|
|
754
|
+
export const WEB_MERCATOR_SIZE = 40075016.68557849;
|
|
755
|
+
/** Mercator Y limit (±85.0511° — where the projection diverges). */
|
|
756
|
+
const MERCATOR_LAT_MAX = 85.0511287798066;
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Longitude (degrees) to EPSG:3857 X (metres). Values outside
|
|
760
|
+
* [-180, 180] map onto neighbouring world copies, as on a wrapping
|
|
761
|
+
* map.
|
|
762
|
+
*
|
|
763
|
+
* @param {number} lng
|
|
764
|
+
* @returns {number}
|
|
765
|
+
*/
|
|
766
|
+
export function lngToX(lng) {
|
|
767
|
+
return (lng / 360 + 0.5) * WEB_MERCATOR_SIZE;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Latitude (degrees) to EPSG:3857 Y (metres), clamped to the
|
|
772
|
+
* projection's range.
|
|
773
|
+
*
|
|
774
|
+
* @param {number} lat
|
|
775
|
+
* @returns {number}
|
|
776
|
+
*/
|
|
777
|
+
export function latToY(lat) {
|
|
778
|
+
const clamped = Math.max(-MERCATOR_LAT_MAX, Math.min(MERCATOR_LAT_MAX, lat));
|
|
779
|
+
const s = Math.sin((clamped * Math.PI) / 180);
|
|
780
|
+
return (
|
|
781
|
+
(0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * WEB_MERCATOR_SIZE
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* EPSG:3857 X (metres) to longitude (degrees), normalized to
|
|
787
|
+
* [-180, 180).
|
|
788
|
+
*
|
|
789
|
+
* @param {number} x
|
|
790
|
+
* @returns {number}
|
|
791
|
+
*/
|
|
792
|
+
export function xToLng(x) {
|
|
793
|
+
return normalizeLng((x / WEB_MERCATOR_SIZE - 0.5) * 360);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* EPSG:3857 Y (metres) to latitude (degrees).
|
|
798
|
+
*
|
|
799
|
+
* @param {number} y
|
|
800
|
+
* @returns {number}
|
|
801
|
+
*/
|
|
802
|
+
export function yToLat(y) {
|
|
803
|
+
const n = Math.PI - (2 * Math.PI * y) / WEB_MERCATOR_SIZE;
|
|
804
|
+
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Normalizes a longitude to [-180, 180).
|
|
809
|
+
*
|
|
810
|
+
* @param {number} lng
|
|
811
|
+
* @returns {number}
|
|
812
|
+
*/
|
|
813
|
+
export function normalizeLng(lng) {
|
|
814
|
+
return ((((lng + 180) % 360) + 360) % 360) - 180;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Ground resolution at a latitude, in metres per screen (CSS) pixel
|
|
819
|
+
* at the given zoom — the 256 px-tile convention Leaflet uses.
|
|
820
|
+
*
|
|
821
|
+
* @param {number} lat
|
|
822
|
+
* @param {number} zoom
|
|
823
|
+
* @returns {number}
|
|
824
|
+
*/
|
|
825
|
+
export function metersPerPixel(lat, zoom) {
|
|
826
|
+
return (
|
|
827
|
+
(WEB_MERCATOR_SIZE * Math.cos((lat * Math.PI) / 180)) / (256 * 2 ** zoom)
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The lon/lat `[west, south, east, north]` form of a mercator extent.
|
|
833
|
+
* A box crossing the antimeridian comes out in the API's form
|
|
834
|
+
* (west > east); longitudes are in [-180, 180].
|
|
835
|
+
*
|
|
836
|
+
* @param {[number, number, number, number]} extent - EPSG:3857 metres
|
|
837
|
+
* @returns {[number, number, number, number]}
|
|
838
|
+
*/
|
|
839
|
+
export function boundsForExtent(extent) {
|
|
840
|
+
const spanDeg = ((extent[2] - extent[0]) / WEB_MERCATOR_SIZE) * 360;
|
|
841
|
+
if (spanDeg >= 360) {
|
|
842
|
+
return [-180, yToLat(extent[1]), 180, yToLat(extent[3])];
|
|
843
|
+
}
|
|
844
|
+
const west = normalizeLng(xToLngRaw(extent[0]));
|
|
845
|
+
let east = west + spanDeg;
|
|
846
|
+
if (east > 180) east -= 360; // crossed the antimeridian
|
|
847
|
+
if (east > 180) east = 180; // degenerate near-world-spanning box
|
|
848
|
+
return [west, yToLat(extent[1]), east, yToLat(extent[3])];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Raw X→longitude, NOT wrapped (for boundsForExtent's span math).
|
|
853
|
+
*
|
|
854
|
+
* @param {number} x
|
|
855
|
+
* @returns {number}
|
|
856
|
+
*/
|
|
857
|
+
function xToLngRaw(x) {
|
|
858
|
+
return (x / WEB_MERCATOR_SIZE - 0.5) * 360;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Builds an `overlay.request` payload from the map view. The image
|
|
863
|
+
* covers the viewport's mercator box, pixels spaced evenly in
|
|
864
|
+
* mercator, north up (this host's chart does not rotate).
|
|
865
|
+
*
|
|
866
|
+
* The extent is the viewport at `resolution` per IMAGE pixel, where
|
|
867
|
+
* image pixels are CSS pixels times `pixelRatio`; the returned bounds
|
|
868
|
+
* are the same box as lon/lat.
|
|
869
|
+
*
|
|
870
|
+
* @param {{centerLng: number, centerLat: number, zoom: number,
|
|
871
|
+
* width: number, height: number}} view - viewport in CSS pixels
|
|
872
|
+
* @param {{overlayId: string, requestId: string, pixelRatio: number,
|
|
873
|
+
* liveInterval?: number}} opts
|
|
874
|
+
* @returns {object} an OverlayRequestEvent payload
|
|
875
|
+
*/
|
|
876
|
+
export function buildFrameRequest(view, opts) {
|
|
877
|
+
const mpp = metersPerPixel(view.centerLat, view.zoom);
|
|
878
|
+
const cx = lngToX(view.centerLng);
|
|
879
|
+
const cy = latToY(view.centerLat);
|
|
880
|
+
const halfW = (view.width * mpp) / 2;
|
|
881
|
+
const halfH = (view.height * mpp) / 2;
|
|
882
|
+
const extent = [cx - halfW, cy - halfH, cx + halfW, cy + halfH];
|
|
883
|
+
const req = {
|
|
884
|
+
overlayId: opts.overlayId,
|
|
885
|
+
requestId: opts.requestId,
|
|
886
|
+
extent,
|
|
887
|
+
bounds: boundsForExtent(extent),
|
|
888
|
+
width: Math.round(view.width * opts.pixelRatio),
|
|
889
|
+
height: Math.round(view.height * opts.pixelRatio),
|
|
890
|
+
pixelRatio: opts.pixelRatio,
|
|
891
|
+
resolution: mpp / opts.pixelRatio,
|
|
892
|
+
zoom: view.zoom,
|
|
893
|
+
rotation: 0,
|
|
894
|
+
};
|
|
895
|
+
if (opts.liveInterval !== undefined) req.liveInterval = opts.liveInterval;
|
|
896
|
+
return req;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Decides whether an incoming `overlay.frame` is drawn. A frame
|
|
901
|
+
* answering a request is accepted only while that request is still
|
|
902
|
+
* the newest (otherwise `overlays.superseded`); a live frame (no
|
|
903
|
+
* `requestId`) is paced against the host's `liveInterval`
|
|
904
|
+
* (`overlays.throttled`). Mutates the gate — it is per-overlay state.
|
|
905
|
+
*
|
|
906
|
+
* @param {{lastRequestId: string|null, lastLiveAt: number,
|
|
907
|
+
* liveInterval: number}} gate
|
|
908
|
+
* @param {{requestId?: string}} params - overlay.frame params
|
|
909
|
+
* @param {number} now - ms timestamp
|
|
910
|
+
* @returns {{accepted: boolean, reason?: string}}
|
|
911
|
+
*/
|
|
912
|
+
export function acceptFrame(gate, params, now) {
|
|
913
|
+
if (params.requestId === undefined || params.requestId === null) {
|
|
914
|
+
if (now - gate.lastLiveAt < gate.liveInterval) {
|
|
915
|
+
return { accepted: false, reason: "overlays.throttled" };
|
|
916
|
+
}
|
|
917
|
+
gate.lastLiveAt = now;
|
|
918
|
+
return { accepted: true };
|
|
919
|
+
}
|
|
920
|
+
if (params.requestId !== gate.lastRequestId) {
|
|
921
|
+
return { accepted: false, reason: "overlays.superseded" };
|
|
922
|
+
}
|
|
923
|
+
// A requested frame is fresh content too: an immediately following
|
|
924
|
+
// live frame adds nothing the viewer has not just been shown.
|
|
925
|
+
gate.lastLiveAt = now;
|
|
926
|
+
return { accepted: true };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Where a frame's image lands on the current viewport, in CSS pixels:
|
|
931
|
+
* the image covers `extent` (its own georeferencing), the view has
|
|
932
|
+
* moved on since — the placement maps the extent into the view now.
|
|
933
|
+
*
|
|
934
|
+
* @param {{extent: [number, number, number, number]}} frame
|
|
935
|
+
* @param {{centerLng: number, centerLat: number, zoom: number,
|
|
936
|
+
* width: number, height: number}} view - current viewport, CSS px
|
|
937
|
+
* @returns {{x: number, y: number, width: number, height: number}}
|
|
938
|
+
*/
|
|
939
|
+
export function framePlacement(frame, view) {
|
|
940
|
+
const mpp = metersPerPixel(view.centerLat, view.zoom);
|
|
941
|
+
const cx = lngToX(view.centerLng);
|
|
942
|
+
const cy = latToY(view.centerLat);
|
|
943
|
+
const viewMinX = cx - (view.width * mpp) / 2;
|
|
944
|
+
const viewMaxY = cy + (view.height * mpp) / 2;
|
|
945
|
+
const [minX, minY, maxX, maxY] = frame.extent;
|
|
946
|
+
return {
|
|
947
|
+
x: (minX - viewMinX) / mpp,
|
|
948
|
+
y: (viewMaxY - maxY) / mpp,
|
|
949
|
+
width: (maxX - minX) / mpp,
|
|
950
|
+
height: (maxY - minY) / mpp,
|
|
951
|
+
};
|
|
952
|
+
}
|