From 86af89f1ebe33b0e1873a9897b9954fe9702e03d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 12:07:12 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20scroll-wheel=20zoom=20=E2=80=94?= =?UTF-8?q?=20overscale=20detent=20+=20clean=20floor=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace MapLibre's native scrollZoom with a dedicated WheelZoom handler so the two overscale limits feel right when zooming with the wheel/trackpad: - Overscale detent: zoom-IN stops dead at the onset of overscale — the zoom whose displayed scale equals the covering chart's compilation scale (coverScale), the same boundary the HUD's "Overscale xN" warning uses. The whole scroll AND its momentum tail are absorbed, so a "throw" can't punch through; you continue into overscale by scrolling again (a fresh gesture releases it). Re-arms once you zoom back below. - Floor: the 1:MIN_DETAIL_SCALE scale floor is computed live per wheel event (getFloor) instead of read from map.getMaxZoom() — which is only re-applied on moveend, so mid-gesture it could be stale-high and let you sail past the floor and bounce back ~a second later on moveend. Now it stops dead at the real max, with a small elastic over-pull that springs back in ~one frame (latched, so a momentum tail can't hold the spring-back open). Speed: each wheel step accumulates onto a private target and applies instantly (duration 0). Reading map.getZoom() instead crawled — easeTo({duration:0}) only updates the camera next frame, so multiple events per frame collapsed to one step. HUD updateZoomCap no longer slams the band cap into setMaxZoom (the hard clamp that caused both the sticky feel and the bounce); it records the overscale- onset zoom as a detent exposed via getDetentZoom(). _applyScaleFloor sets maxZoom = floor + FLOOR_GIVE for the elastic headroom. own-ship drops its own wheel takeover and just registers a follow anchor with WheelZoom, so vessel- anchored zoom and the detent/floor behavior share one handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 18 ++++- web/src/lib/util.mjs | 5 ++ web/src/plugins/hud.mjs | 27 +++++-- web/src/plugins/own-ship.mjs | 45 ++--------- web/src/plugins/wheel-zoom.mjs | 136 +++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 web/src/plugins/wheel-zoom.mjs diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 8d93a52..a8ad91a 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -38,11 +38,12 @@ import { ChartStore } from "./data/chart-store.mjs"; import { UNIT_DEFAULTS } from "./lib/units.mjs"; // configurable display units (categories now in core-settings.mjs) import { ChartFinder } from "./plugins/chart-finder.mjs"; // off-screen installed-chart edge pointers import { HudController } from "./plugins/hud.mjs"; // status readout + overscale zoom cap +import { WheelZoom } from "./plugins/wheel-zoom.mjs"; // scroll-wheel zoom: band detent + elastic floor import { CoverageBoxes } from "./plugins/coverage-boxes.mjs"; // installed-chart coverage overlay import { OrientationControl } from "./plugins/orientation-control.mjs"; // compass: north-up / free orientation import { SearchBox } from "./plugins/search-box.mjs"; // offline catalog + chart-feature search import { BANDS, BAND_LABEL, BAND_COLOR, BAND_MINZOOM, DEV_BANDS, bandForScale } from "./lib/bands.mjs"; -import { loadJSON, maxZoomForScaleFloor, freshness, fmtIssue, fmtMB, isShareUrl, parseViewHash, copyText, flashBtn } from "./lib/util.mjs"; +import { loadJSON, maxZoomForScaleFloor, FLOOR_GIVE, freshness, fmtIssue, fmtMB, isShareUrl, parseViewHash, copyText, flashBtn } from "./lib/util.mjs"; import { archivePut, archiveGet } from "./data/archive-store.mjs"; import { STYLE, CHROME } from "./chartplotter.view.mjs"; // shell chrome (CSS + static markup) @@ -563,6 +564,15 @@ export class ChartPlotter extends HTMLElement { getPxPitch: () => this._pxPitch, // calibrated physical CSS-pixel pitch (mm) for the on-screen scale }); + // Scroll-wheel zoom: owns the wheel (native scrollZoom off) to give the band + // overscale cap a soft detent and the scale floor a small elastic stop. Reads + // the detent from the HUD; own-ship registers a follow anchor below. + this._wheelZoom = new WheelZoom({ + map, + getDetent: () => this._hud.getDetentZoom(), + getFloor: () => maxZoomForScaleFloor(map.getCenter().lat), // live 1:MIN_DETAIL_SCALE floor (matches _applyScaleFloor) + }); + // Compass / orientation control: a round button in the top-right group; tap to // cycle north-up ↔ free and reorient, needle tracks the live bearing. Drives the // renderer's camera API. @@ -619,7 +629,7 @@ export class ChartPlotter extends HTMLElement { map.on("click", () => tinfo.hide()); // Own-ship marker + course predictor; follows the vessel (break-out + a // re-centre chip mounted in the shell chrome) and streams fixes to the camera. - this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner }); + this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner, wheelZoom: this._wheelZoom }); // AIS targets (other vessels) from the live feed. this._ais = new AISOverlay({ map, assets: this._assets, prod: this._prod, onSelect: showInfo, units: () => this._mariner }); } @@ -1360,7 +1370,9 @@ export class ChartPlotter extends HTMLElement { // latitude — a consistent scale floor rather than a per-location data cap. _applyScaleFloor() { if (!this._map) return; - const mz = maxZoomForScaleFloor(this._map.getCenter().lat); + // FLOOR_GIVE headroom above the floor so WheelZoom can let a hard-in scroll + // over-pull a hair past it and settle back (a stop with give, not a wall). + const mz = maxZoomForScaleFloor(this._map.getCenter().lat) + FLOOR_GIVE; if (Math.abs(this._map.getMaxZoom() - mz) > 1e-3) this._map.setMaxZoom(mz); } diff --git a/web/src/lib/util.mjs b/web/src/lib/util.mjs index 06295f9..f3e7d11 100644 --- a/web/src/lib/util.mjs +++ b/web/src/lib/util.mjs @@ -79,6 +79,11 @@ export function zoomForScalePhysical(scale, lat, pxPitchMm = DEFAULT_PX_PITCH_MM // this it's just blocky overzoom). The cap is a PHYSICAL scale — the 1:N a ruler // laid on the screen measures (scaleDenomPhysical) — so it matches the HUD readout. export const MIN_DETAIL_SCALE = 4000; +// A sliver of zoom headroom kept above the scale floor (set as the map's real +// maxZoom in _applyScaleFloor) so the wheel-zoom handler can let a hard-in scroll +// over-pull a hair past the floor and settle back — a stop with a little give, +// not a wall that bounces. See WheelZoom. +export const FLOOR_GIVE = 0.15; export function maxZoomForScaleFloor(lat, pxPitchMm = DEFAULT_PX_PITCH_MM) { // Inverse of scaleDenomPhysical: the (fractional) zoom whose physical scale at // `lat` equals the floor (latitude-dependent). Uses the same px pitch as the diff --git a/web/src/plugins/hud.mjs b/web/src/plugins/hud.mjs index a3f1d1d..f1ec045 100644 --- a/web/src/plugins/hud.mjs +++ b/web/src/plugins/hud.mjs @@ -7,7 +7,7 @@ // root = the shadowRoot (for #cov-readout / #databox / #db-warn); cellMeta(name) // → {s:scale, bb:[w,s,e,n]} | undefined; serverSetMetas() → [{band, bounds}]. -import { bandForScale, bandForZoom, BANDS, BAND_COLOR, BAND_LABEL, BAND_MAXZOOM, OVERSCALE_MARGIN } from "../lib/bands.mjs"; +import { bandForScale, bandForZoom, BANDS, BAND_COLOR, BAND_LABEL, BAND_MAXZOOM } from "../lib/bands.mjs"; import { scaleDenomPhysical, zoomForScalePhysical, fmtScale, fmtLatLon } from "../lib/util.mjs"; // Parse a user-typed scale into a denominator. Accepts "40000", "40,000", @@ -38,6 +38,7 @@ export class HudController { // default (CSS reference). The user calibrates it in settings. this.getPxPitch = opts.getPxPitch || (() => undefined); this.coverScale = 0; // finest covering chart's CSCL — the overscale ×n reference + this.detentZoom = null; // finest covering band's overscale cap — the wheel-zoom detent (not a hard maxZoom) this._onMove = () => this.updateHud(); this.map.on("move", this._onMove); this.updateHud(); @@ -134,10 +135,12 @@ export class HudController { } } - // Overscale cap: limit zoom-IN to the finest band whose chart actually covers the - // view centre (+ OVERSCALE_MARGIN) — past that, open water just enlarges blank - // sea. Never drops below the current zoom (no camera yank). Also records - // coverScale (the centre chart's CSCL) for the overscale indication. + // Overscale detent: the native-max zoom of the finest band whose chart actually + // covers the view centre — the click where overscale begins. Exposed via + // getDetentZoom() so WheelZoom stops zoom-in there briefly (then a sustained + // scroll releases past it, on into overscale); it is NOT a hard maxZoom, so other + // inputs can still reach the scale floor. Also records coverScale (the centre + // chart's CSCL) for the overscale indication. updateZoomCap() { const map = this.map; if (!map) return; @@ -165,9 +168,17 @@ export class HudController { } this.coverScale = finestScale; const band = finest >= 0 ? BANDS[finest] : "general"; - const target = Math.min(18, (BAND_MAXZOOM[band] || 9) + OVERSCALE_MARGIN); - const cap = Math.max(target, map.getZoom()); // never below current zoom → no yank - if (Math.abs(map.getMaxZoom() - cap) > 0.01) map.setMaxZoom(cap); + // Detent right where overscale BEGINS for the covering chart: the zoom whose + // displayed (physical) scale equals the chart's compilation scale, coverScale. + // Zoom past it and dispDenom < coverScale → "Overscale ×N" (same test as the + // warning above). Falls back to the band's native-max zoom when no per-chart + // scale is known (e.g. server sets without the NOAA catalogue). + this.detentZoom = finestScale + ? zoomForScalePhysical(finestScale, c.lat, this.getPxPitch()) + : Math.min(18, BAND_MAXZOOM[band] || 9); this.updateHud(); } + + // The current overscale detent zoom (finest covering band's cap), for WheelZoom. + getDetentZoom() { return this.detentZoom; } } diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index 06ed286..77527f6 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -43,7 +43,7 @@ const CHIP_STYLE = ` const EMPTY = { type: "FeatureCollection", features: [] }; export class OwnShip { - constructor({ map, plotter, vessel, host, onSelect, units, predictMin = 6 } = {}) { + constructor({ map, plotter, vessel, host, onSelect, units, wheelZoom, predictMin = 6 } = {}) { this._map = map; this._plotter = plotter; this._vessel = vessel; @@ -55,7 +55,6 @@ export class OwnShip { this._centered = false; // recenter once on the first fix so the boat is on-screen this._follow = true; // keep the vessel centred until the user pans away this._fix = null; // latest {lng, lat, courseDeg} - this._zoomTakeover = false; // whether we've taken over the wheel from native scroll-zoom this._last = null; // last predictor GeoJSON (to restore after style reload) // Smooth display: the rendered pose is interpolated fix→fix (in step with the // camera ease in ) so the boat glides instead of hopping each fix. @@ -79,22 +78,10 @@ export class OwnShip { this._onDrag = () => this._setFollow(false); map.on("dragstart", this._onDrag); - // While following, zoom anchors on the vessel, not the cursor. We can't just - // recentre each zoom frame — that cancels MapLibre's scroll-zoom animation and - // makes zooming crawl. Instead, disable native scroll-zoom and handle the wheel - // ourselves: an instant zoom around the boat (instant, so rapid scrolls add up). - // Panning re-enables native cursor-anchored zoom until re-centre. - this._canvas = map.getCanvasContainer(); - this._onWheel = (e) => { - if (!this._follow || !this._fix) return; - e.preventDefault(); - let d = e.deltaY; - if (e.deltaMode === 1) d *= 28; // lines → ~pixels - else if (e.deltaMode === 2) d *= 400; // pages - const z = clamp(map.getZoom() - d * 0.006, map.getMinZoom(), map.getMaxZoom()); - map.easeTo({ zoom: z, around: [this._fix.lng, this._fix.lat], duration: 0 }); - }; - this._applyFollowZoom(); + // While following, wheel-zoom anchors on the vessel rather than the cursor. + // WheelZoom owns the wheel (detent + elastic floor); we just feed it the anchor + // so it zooms around the boat whenever we're following a fix. null → cursor. + if (wheelZoom) wheelZoom.setAnchorProvider(() => (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null); // Defer layer creation until the style is ready (see _ensureLayers); subscribe // first so a not-yet-loaded style can't abort the constructor before we do. @@ -127,27 +114,9 @@ export class OwnShip { _setFollow(on) { this._follow = on; - this._applyFollowZoom(); this._syncChip(); } - // Take over the wheel only while following AND we have a fix to anchor on; - // otherwise leave native (cursor-anchored) scroll-zoom enabled — including before - // the first fix, so chart browsing zooms normally. Idempotent. - _applyFollowZoom() { - if (!this._canvas) return; - const takeover = this._follow && !!this._fix; - if (takeover === this._zoomTakeover) return; - this._zoomTakeover = takeover; - if (takeover) { - this._map.scrollZoom.disable(); - this._canvas.addEventListener("wheel", this._onWheel, { passive: false }); - } else { - this._canvas.removeEventListener("wheel", this._onWheel, { passive: false }); - this._map.scrollZoom.enable(); - } - } - _syncChip() { if (this._chip) this._chip.hidden = this._follow || !this._fix; } @@ -235,7 +204,6 @@ export class OwnShip { this._plotter.updateFollow(this._fix); } } - this._applyFollowZoom(); // now that we have a fix, take over the wheel if following this._syncChip(); } @@ -303,7 +271,6 @@ export class OwnShip { this._last = EMPTY; const src = this._map && this._map.getSource(SRC); if (src) src.setData(EMPTY); - this._applyFollowZoom(); // no fix → hand the wheel back to native scroll-zoom this._syncChip(); } @@ -312,8 +279,6 @@ export class OwnShip { if (this._off) this._off(); if (this._onStyle) this._map.off("style.load", this._onStyle); if (this._onDrag) this._map.off("dragstart", this._onDrag); - if (this._canvas && this._onWheel) this._canvas.removeEventListener("wheel", this._onWheel, { passive: false }); - this._map.scrollZoom.enable(); if (this._marker) this._marker.remove(); if (this._chip) this._chip.remove(); this._plotter.removeOverlay([CASING, LINE], SRC); diff --git a/web/src/plugins/wheel-zoom.mjs b/web/src/plugins/wheel-zoom.mjs new file mode 100644 index 0000000..6de7ea4 --- /dev/null +++ b/web/src/plugins/wheel-zoom.mjs @@ -0,0 +1,136 @@ +// wheel-zoom.mjs — owns scroll-wheel zoom for the chart, replacing MapLibre's +// native scrollZoom so the two overscale limits feel right: +// +// • Overscale detent — zooming IN stops dead at the onset of overscale (the zoom +// whose display scale equals the covering chart's compilation scale, supplied +// by the HUD as getDetentZoom). The whole scroll AND its momentum tail are +// absorbed, so a "throw" can't punch through; to continue into overscale you +// scroll AGAIN (a fresh gesture releases it). Re-arms once you zoom back below. +// +// • Floor — zooming IN stops at the 1:MIN_DETAIL_SCALE scale floor (the map's +// real maxZoom, set with a FLOOR_GIVE sliver of headroom). A hard scroll +// over-pulls a hair past it and settles back: a stop with a little give, not +// a wall that springs back. +// +// We accumulate onto our OWN target zoom and jump the camera to it each event. +// Reading map.getZoom() instead would crawl: easeTo({duration:0}) applies on the +// next render frame, so several wheel events fired between frames (every trackpad +// gesture) all read the same stale zoom and only the last step per frame survives. +// A private target adds up every event regardless of frame timing. A fresh gesture +// (after a quiet gap, or after pinch/flyTo moved the camera) re-bases on the live +// zoom. Anchored on the cursor by default; a follow plugin (own-ship) can register +// an anchor provider to zoom around the vessel. +// +// const wz = new WheelZoom({ map, getDetent: () => hud.getDetentZoom() }); +// wz.setAnchorProvider(() => following ? [lng, lat] : null); + +import { FLOOR_GIVE } from "../lib/util.mjs"; + +const ZOOM_RATE = 0.009; // zoom levels per wheel-pixel of deltaY +const GESTURE_GAP_MS = 200; // quiet time that ends a gesture (next event re-bases on live zoom) +const SETTLE_DELAY_MS = 40; // fixed delay from first floor contact before springing back +const SETTLE_MS = 120; // spring-back ease length +const ELASTIC = 0.28; // fraction of a step applied once past the floor (damped over-pull) + +export class WheelZoom { + constructor({ map, getDetent, getFloor } = {}) { + this._map = map; + this._getDetent = getDetent || (() => null); + // The live 1:MIN_DETAIL_SCALE zoom floor (rest cap). Computed PER EVENT, not read + // from map.getMaxZoom() — that's only re-applied on moveend, so mid-gesture it can + // be stale-high (initial z18, or a value a flyTo left raised) and we'd sail past + // the floor and only snap back on moveend (the "bounce"). A live floor stops dead. + this._getFloor = getFloor || (() => map.getMaxZoom() - FLOOR_GIVE); + this._anchor = null; // optional () => [lng,lat] | null (follow anchor) + this._target = null; // accumulated target zoom; null until the first gesture + this._lastTs = 0; // timestamp of the previous wheel event (gesture detection) + this._detentArmed = true; // true below the detent; cleared once a fresh gesture releases it + this._detentParked = false; // currently stopped AT the detent (absorbing a scroll + its momentum) + this._floorLatched = false; // gave the one elastic over-pull this floor contact → now hard-stop + this._settleT = 0; // pending floor settle-back (fixed delay, not reset by momentum) + + this._canvas = map.getCanvasContainer(); + this._onWheel = (e) => this._wheel(e); + map.scrollZoom.disable(); // we own the wheel now + this._canvas.addEventListener("wheel", this._onWheel, { passive: false }); + } + + // A follow plugin registers a function returning the geographic point to keep + // fixed while zooming (the vessel), or null to fall back to the cursor. + setAnchorProvider(fn) { this._anchor = fn; } + + destroy() { + if (this._canvas) this._canvas.removeEventListener("wheel", this._onWheel, { passive: false }); + if (this._settleT) { clearTimeout(this._settleT); this._settleT = 0; } + if (this._map && this._map.scrollZoom) this._map.scrollZoom.enable(); + } + + _wheel(e) { + e.preventDefault(); + const map = this._map; + let d = e.deltaY; + if (e.deltaMode === 1) d *= 28; // lines → ~pixels + else if (e.deltaMode === 2) d *= 400; // pages + if (!d) return; + + const min = map.getMinZoom(); + const floor = this._getFloor(); // live scale floor (resting cap) + const hardMax = floor + FLOOR_GIVE; // the give is the elastic over-pull headroom + const zoomingIn = d < 0; + const step = -d * ZOOM_RATE; // + zoom-in / − zoom-out + + // A fresh gesture starts after a quiet gap (or the camera moved under us via + // pinch/flyTo). Re-base the target on the live zoom; within a gesture, accumulate. + const newGesture = this._target == null || (e.timeStamp - this._lastTs) > GESTURE_GAP_MS; + if (newGesture) this._target = map.getZoom(); + this._lastTs = e.timeStamp; + + const from = this._target; // where we are on our own clock + let next = from + step; + + // Re-arm the detent / clear the floor latch whenever we've dropped back below them. + const detent = this._getDetent(); + if (detent != null && from < detent - 0.05) { this._detentArmed = true; this._detentParked = false; } + if (from < floor - 1e-3) this._floorLatched = false; + + if (zoomingIn) { + // Hard detent at overscale onset: the whole scroll AND its momentum tail are + // stopped dead here — a "throw" can't punch through. To continue into + // overscale you scroll AGAIN: a fresh gesture while parked releases it. + if (detent != null && detent < floor && this._detentArmed && from <= detent + 1e-3 && next > detent) { + if (this._detentParked && newGesture) { this._detentArmed = false; this._detentParked = false; } + else { next = detent; this._detentParked = true; } + } + // Floor: one small damped over-pull that springs back, then a hard stop for + // the rest of this contact. Latching keeps a trackpad's momentum tail from + // re-pulling (buzz) or pushing the spring-back out by ~a second. + if (next > floor) { + if (this._floorLatched) { next = floor; } + else { next = Math.min(hardMax, floor + step * ELASTIC); this._floorLatched = true; this._scheduleSettle(floor); } + } + } + + next = Math.max(min, Math.min(next, hardMax)); + this._target = next; + if (Math.abs(next - map.getZoom()) < 1e-4) return; + + const around = (this._anchor && this._anchor()) || this._cursorLngLat(e); + map.easeTo({ zoom: next, around, duration: 0 }); + } + + // Spring the elastic over-pull back to the floor on a SHORT FIXED delay from the + // first contact — not reset by later events, so a trackpad's momentum tail can't + // hold the spring-back open for ~a second. + _scheduleSettle(floor) { + if (this._settleT) return; + this._settleT = setTimeout(() => { + this._settleT = 0; + if (this._map.getZoom() > floor + 1e-3) { this._target = floor; this._map.easeTo({ zoom: floor, duration: SETTLE_MS }); } + }, SETTLE_DELAY_MS); + } + + _cursorLngLat(e) { + const r = this._canvas.getBoundingClientRect(); + return this._map.unproject([e.clientX - r.left, e.clientY - r.top]); + } +} From 872d179b912dd654fb3a8ad595fc52d06f3bf82a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 12:13:06 -0400 Subject: [PATCH 2/2] refactor(web): inject the wheel-zoom anchor instead of plugins pushing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WheelZoom no longer exposes a mutable setAnchorProvider() that plugins reach in and call (single-slot, last-writer-wins, and it coupled own-ship to WheelZoom's API). Anchoring is now an injected accessor — getAnchor() — symmetric with getDetent/getFloor: WheelZoom just asks where to anchor. The shell is the broker. ChartPlotter._zoomAnchor() composes the anchor from the plugins that can contribute one (first non-null wins; null → cursor), and hands that to WheelZoom. own-ship drops its WheelZoom dependency entirely and instead exposes a zoomAnchor() capability (vessel while following a fix, else null) that the shell reads. Future camera plugins slot into the broker without WheelZoom changing, and multiple contributors compose instead of clobbering. Behavior unchanged; verified the injected path end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 11 ++++++++++- web/src/plugins/own-ship.mjs | 14 ++++++++------ web/src/plugins/wheel-zoom.mjs | 23 ++++++++++++----------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index a8ad91a..995ec45 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -571,6 +571,7 @@ export class ChartPlotter extends HTMLElement { map, getDetent: () => this._hud.getDetentZoom(), getFloor: () => maxZoomForScaleFloor(map.getCenter().lat), // live 1:MIN_DETAIL_SCALE floor (matches _applyScaleFloor) + getAnchor: () => this._zoomAnchor(), // plugins contribute where zoom should anchor (vessel while following, else cursor) }); // Compass / orientation control: a round button in the top-right group; tap to @@ -629,7 +630,7 @@ export class ChartPlotter extends HTMLElement { map.on("click", () => tinfo.hide()); // Own-ship marker + course predictor; follows the vessel (break-out + a // re-centre chip mounted in the shell chrome) and streams fixes to the camera. - this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner, wheelZoom: this._wheelZoom }); + this._ownShip = new OwnShip({ map, plotter: this._plotter, vessel: this._vessel, host: this.shadowRoot, onSelect: showInfo, units: () => this._mariner }); // AIS targets (other vessels) from the live feed. this._ais = new AISOverlay({ map, assets: this._assets, prod: this._prod, onSelect: showInfo, units: () => this._mariner }); } @@ -1376,6 +1377,14 @@ export class ChartPlotter extends HTMLElement { if (Math.abs(this._map.getMaxZoom() - mz) > 1e-3) this._map.setMaxZoom(mz); } + // Broker for WheelZoom: the geographic point wheel-zoom should keep fixed while + // zooming, composed from the plugins that can contribute one (first non-null + // wins; null → cursor-anchored). Today only own-ship anchors on the vessel while + // following; future camera plugins slot in here without WheelZoom changing. + _zoomAnchor() { + return (this._ownShip && this._ownShip.zoomAnchor()) || null; + } + // List the catalog cells intersecting the current viewport in the coverage // panel, grouped by band (finest first). Downloaded cells show plain; cells // that aren't downloaded are flagged and tap-to-download (jump to their NOAA diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index 77527f6..45ca3df 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -43,7 +43,7 @@ const CHIP_STYLE = ` const EMPTY = { type: "FeatureCollection", features: [] }; export class OwnShip { - constructor({ map, plotter, vessel, host, onSelect, units, wheelZoom, predictMin = 6 } = {}) { + constructor({ map, plotter, vessel, host, onSelect, units, predictMin = 6 } = {}) { this._map = map; this._plotter = plotter; this._vessel = vessel; @@ -78,11 +78,6 @@ export class OwnShip { this._onDrag = () => this._setFollow(false); map.on("dragstart", this._onDrag); - // While following, wheel-zoom anchors on the vessel rather than the cursor. - // WheelZoom owns the wheel (detent + elastic floor); we just feed it the anchor - // so it zooms around the boat whenever we're following a fix. null → cursor. - if (wheelZoom) wheelZoom.setAnchorProvider(() => (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null); - // Defer layer creation until the style is ready (see _ensureLayers); subscribe // first so a not-yet-loaded style can't abort the constructor before we do. this._onStyle = () => this._ensureLayers(); @@ -117,6 +112,13 @@ export class OwnShip { this._syncChip(); } + // Plugin contract (consumed by WheelZoom via the shell): the geographic point + // wheel-zoom should keep fixed while zooming — the vessel while we're following + // a fix, else null so it falls back to cursor-anchored zoom. + zoomAnchor() { + return (this._follow && this._fix) ? [this._fix.lng, this._fix.lat] : null; + } + _syncChip() { if (this._chip) this._chip.hidden = this._follow || !this._fix; } diff --git a/web/src/plugins/wheel-zoom.mjs b/web/src/plugins/wheel-zoom.mjs index 6de7ea4..b3982ec 100644 --- a/web/src/plugins/wheel-zoom.mjs +++ b/web/src/plugins/wheel-zoom.mjs @@ -18,11 +18,16 @@ // gesture) all read the same stale zoom and only the last step per frame survives. // A private target adds up every event regardless of frame timing. A fresh gesture // (after a quiet gap, or after pinch/flyTo moved the camera) re-bases on the live -// zoom. Anchored on the cursor by default; a follow plugin (own-ship) can register -// an anchor provider to zoom around the vessel. +// zoom. // -// const wz = new WheelZoom({ map, getDetent: () => hud.getDetentZoom() }); -// wz.setAnchorProvider(() => following ? [lng, lat] : null); +// Anchoring is injected, not pushed: getAnchor() returns the geographic point to +// keep fixed while zooming (e.g. the vessel when a follow plugin is active), or +// null for cursor-anchored. The shell composes it from whatever plugins contribute +// (see ChartPlotter), so plugins never reach into WheelZoom — symmetric with +// getDetent/getFloor. +// +// const wz = new WheelZoom({ map, getDetent: () => hud.getDetentZoom(), +// getFloor: () => floorZoom(), getAnchor: () => anchor() }); import { FLOOR_GIVE } from "../lib/util.mjs"; @@ -33,15 +38,15 @@ const SETTLE_MS = 120; // spring-back ease length const ELASTIC = 0.28; // fraction of a step applied once past the floor (damped over-pull) export class WheelZoom { - constructor({ map, getDetent, getFloor } = {}) { + constructor({ map, getDetent, getFloor, getAnchor } = {}) { this._map = map; this._getDetent = getDetent || (() => null); + this._getAnchor = getAnchor || (() => null); // [lng,lat] to keep fixed while zooming, or null → cursor // The live 1:MIN_DETAIL_SCALE zoom floor (rest cap). Computed PER EVENT, not read // from map.getMaxZoom() — that's only re-applied on moveend, so mid-gesture it can // be stale-high (initial z18, or a value a flyTo left raised) and we'd sail past // the floor and only snap back on moveend (the "bounce"). A live floor stops dead. this._getFloor = getFloor || (() => map.getMaxZoom() - FLOOR_GIVE); - this._anchor = null; // optional () => [lng,lat] | null (follow anchor) this._target = null; // accumulated target zoom; null until the first gesture this._lastTs = 0; // timestamp of the previous wheel event (gesture detection) this._detentArmed = true; // true below the detent; cleared once a fresh gesture releases it @@ -55,10 +60,6 @@ export class WheelZoom { this._canvas.addEventListener("wheel", this._onWheel, { passive: false }); } - // A follow plugin registers a function returning the geographic point to keep - // fixed while zooming (the vessel), or null to fall back to the cursor. - setAnchorProvider(fn) { this._anchor = fn; } - destroy() { if (this._canvas) this._canvas.removeEventListener("wheel", this._onWheel, { passive: false }); if (this._settleT) { clearTimeout(this._settleT); this._settleT = 0; } @@ -114,7 +115,7 @@ export class WheelZoom { this._target = next; if (Math.abs(next - map.getZoom()) < 1e-4) return; - const around = (this._anchor && this._anchor()) || this._cursorLngLat(e); + const around = this._getAnchor() || this._cursorLngLat(e); map.easeTo({ zoom: next, around, duration: 0 }); }