diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 8d93a52..995ec45 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,16 @@ 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) + 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 // cycle north-up ↔ free and reorient, needle tracks the live bearing. Drives the // renderer's camera API. @@ -1360,10 +1371,20 @@ 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); } + // 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/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..45ca3df 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -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,23 +78,6 @@ 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(); - // 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(); @@ -127,25 +109,14 @@ 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(); - } + // 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() { @@ -235,7 +206,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 +273,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 +281,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..b3982ec --- /dev/null +++ b/web/src/plugins/wheel-zoom.mjs @@ -0,0 +1,137 @@ +// 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. +// +// 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"; + +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, 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._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 }); + } + + 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._getAnchor() || 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]); + } +}