@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 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 2025) 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 exactly that contingency for Signal K vessels: the fallback is always running, and the trusted-GPS period is spent training it.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.4",
3
+ "version": "0.14.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/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 entries (unknown sections are
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[], panels: 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
- if (widgets.length === 0 && panels.length === 0) continue;
95
- out.set(extId, { manifest, widgets, panels });
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
+ }