Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions web/src/chartplotter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions web/src/lib/util.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 19 additions & 8 deletions web/src/plugins/hud.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
43 changes: 5 additions & 38 deletions web/src/plugins/own-ship.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chart-canvas>) so the boat glides instead of hopping each fix.
Expand All @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand All @@ -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);
Expand Down
137 changes: 137 additions & 0 deletions web/src/plugins/wheel-zoom.mjs
Original file line number Diff line number Diff line change
@@ -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]);
}
}