Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Snap to integer zoom level #3532

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
33 changes: 32 additions & 1 deletion src/ui/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,31 @@ export type JumpToOptions = CameraOptions & {
padding?: PaddingOptions;
}

/**
* The options object related to the {@link Map#shouldSnapToIntegerZoom} method
*/
export type SnapToIntegerZoomOptions = {
/**
* Whether snaps to integer zoom levels when box zooming.
*/
boxZoom: boolean;

/**
* Whether snaps to integer zoom levels when double click zooming.
*/
clickZoom: boolean;

/**
* Whether snaps to integer zoom levels when scroll zooming.
*/
scrollZoom: boolean;

/**
* Whether snaps to integer zoom levels when tap zooming.
*/
tapZoom: boolean;
}

/**
* A options object for the {@link Map#cameraForBounds} method
*/
Expand All @@ -113,6 +138,11 @@ export type CameraForBoundsOptions = CameraOptions & {
* The maximum zoom level to allow when the camera would transition to the specified bounds.
*/
maxZoom?: number;

/**
* Whether the map should snap to integer zoom levels during the transition.
*/
shouldSnapToIntegerZoom?: boolean;
quinncnl marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -728,7 +758,8 @@ export abstract class Camera extends Evented {
return undefined;
}

const zoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
const floatZoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
const zoom = options.shouldSnapToIntegerZoom ? Math.round(floatZoom) : floatZoom;

// Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding.
const offset = Point.convert(options.offset);
Expand Down
6 changes: 5 additions & 1 deletion src/ui/handler/box_zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ export class BoxZoomHandler implements Handler {
} else {
this._map.fire(new Event('boxzoomend', {originalEvent: e}));
return {
cameraAnimation: map => map.fitScreenCoordinates(p0, p1, this._tr.bearing, {linear: true})
cameraAnimation: map => map.fitScreenCoordinates(p0, p1, this._tr.bearing,
{
linear: true,
shouldSnapToIntegerZoom: this._map.shouldSnapToIntegerZoom('boxZoom')
})
};
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/ui/handler/click_zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ export class ClickZoomHandler implements Handler {
e.preventDefault();
return {
cameraAnimation: (map: Map) => {
const floatZoomTarget = this._tr.zoom + (e.shiftKey ? -1 : 1);
map.easeTo({
duration: 300,
zoom: this._tr.zoom + (e.shiftKey ? -1 : 1),
zoom: map.shouldSnapToIntegerZoom('clickZoom') ? Math.round(floatZoomTarget) : floatZoomTarget,
around: this._tr.unproject(point)
}, {originalEvent: e});
}
Expand Down
31 changes: 27 additions & 4 deletions src/ui/handler/scroll_zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ export class ScrollZoomHandler implements Handler {
if (this._map.cooperativeGestures.isEnabled() && !e[this._map.cooperativeGestures._bypassKey]) {
return;
}

let value = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY;

const now = browser.now(),
timeDelta = now - (this._lastWheelEventTime || 0);

Expand All @@ -170,11 +172,13 @@ export class ScrollZoomHandler implements Handler {

} else if (timeDelta > 400) {
// This is likely a new scroll action.
this._type = null;
this._lastValue = value;
if (!this._map.shouldSnapToIntegerZoom('scrollZoom')) {
this._type = null;
this._lastValue = value;

// Start a timeout in case this was a singular event, and dely it by up to 40ms.
this._timeout = setTimeout(this._onTimeout, 40, e);
// Start a timeout in case this was a singular event, and dely it by up to 40ms.
this._timeout = setTimeout(this._onTimeout, 40, e);
}

} else if (!this._type) {
// This is a repeating event, but we don't know the type of event just yet.
Expand All @@ -193,6 +197,24 @@ export class ScrollZoomHandler implements Handler {
// Slow down zoom if shift key is held for more precise zooming
if (e.shiftKey && value) value = value / 4;

if (this._map.shouldSnapToIntegerZoom('scrollZoom') && value !== 0) {
const pos = DOM.mousePos(this._map.getCanvas(), e);
e.preventDefault();
let zoomTarget = value > 100 ? this._map.getZoom() - 2 :
value > 0 ? this._map.getZoom() - 1 :
value > -100 ? this._map.getZoom() + 1 :
value < -100 ? this._map.getZoom() + 2 : 0;

zoomTarget = Math.round(zoomTarget);

this._map.easeTo({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like there is a explicit animation code for regular zoom, how come this can be avoided using easeTo instead of writing the animation code (using frames etc)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit animation is about smoothOutEasing which doesn't have a targetZoom. The end zoom is based on an easing function which is hard to ease to an integer level. So I just skip that and use a simple easeTo instead.

duration: 200,
zoom: zoomTarget,
around: this._tr.unproject(pos)
}, {originalEvent: e});
return;
}

// Only fire the callback if we actually know what type of scrolling device the user uses.
if (this._type) {
this._lastWheelEvent = e;
Expand Down Expand Up @@ -283,6 +305,7 @@ export class ScrollZoomHandler implements Handler {

const targetZoom = typeof this._targetZoom === 'number' ?
this._targetZoom : tr.zoom;

const startZoom = this._startZoom;
const easing = this._easing;

Expand Down
14 changes: 8 additions & 6 deletions src/ui/handler/tap_zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@ export class TapZoomHandler implements Handler {
e.preventDefault();
setTimeout(() => this.reset(), 0);
return {
cameraAnimation: (map: Map) => map.easeTo({
duration: 300,
zoom: tr.zoom + 1,
around: tr.unproject(zoomInPoint)
}, {originalEvent: e})
cameraAnimation: (map: Map) => {
map.easeTo({
duration: 300,
zoom: map.shouldSnapToIntegerZoom('tapZoom') ? Math.round(tr.zoom + 1) : tr.zoom + 1,
around: tr.unproject(zoomInPoint)
}, {originalEvent: e});
}
};
} else if (zoomOutPoint) {
this._active = true;
Expand All @@ -68,7 +70,7 @@ export class TapZoomHandler implements Handler {
return {
cameraAnimation: (map: Map) => map.easeTo({
duration: 300,
zoom: tr.zoom - 1,
zoom: map.shouldSnapToIntegerZoom('tapZoom') ? Math.round(tr.zoom - 1) : tr.zoom - 1,
around: tr.unproject(zoomOutPoint)
}, {originalEvent: e})
};
Expand Down
34 changes: 33 additions & 1 deletion src/ui/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type {LngLatBoundsLike} from '../geo/lng_lat_bounds';
import type {AddLayerObject, FeatureIdentifier, StyleOptions, StyleSetterOptions} from '../style/style';
import type {MapDataEvent} from './events';
import type {StyleImage, StyleImageInterface, StyleImageMetadata} from '../style/style_image';
import type {PointLike} from './camera';
import type {PointLike, SnapToIntegerZoomOptions} from './camera';
import type {ScrollZoomHandler} from './handler/scroll_zoom';
import type {BoxZoomHandler} from './handler/box_zoom';
import type {AroundCenterOptions, TwoFingersTouchPitchHandler} from './handler/two_fingers_touch';
Expand All @@ -61,6 +61,8 @@ import type {QueryRenderedFeaturesOptions, QuerySourceFeatureOptions} from '../s

const version = packageJSON.version;

export type SnapToIntegerZoomType = keyof SnapToIntegerZoomOptions;

/**
* The {@link Map} options object.
*/
Expand Down Expand Up @@ -146,6 +148,13 @@ export type MapOptions = {
* @defaultValue 22
*/
maxZoom?: number | null;

/**
* Zooming options related to restricting to integer levels.
* Useful for raster tiles.
*/
snapToIntegerZoomOptions?: SnapToIntegerZoomOptions | null;

/**
* The minimum pitch of the map (0-85). Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project.
* @defaultValue 0
Expand Down Expand Up @@ -350,6 +359,7 @@ const defaultOptions = {

minZoom: defaultMinZoom,
maxZoom: defaultMaxZoom,
snapToIntegerZoomOptions: {},

minPitch: defaultMinPitch,
maxPitch: defaultMaxPitch,
Expand Down Expand Up @@ -473,6 +483,7 @@ export class Map extends Camera {
_overridePixelRatio: number | null;
_maxCanvasSize: [number, number];
_terrainDataCallback: (e: MapStyleDataEvent | MapSourceDataEvent) => void;
_snapToIntegerZoomOptions: SnapToIntegerZoomOptions;

/**
* @internal
Expand Down Expand Up @@ -580,6 +591,7 @@ export class Map extends Camera {
this._overridePixelRatio = options.pixelRatio;
this._maxCanvasSize = options.maxCanvasSize;
this.transformCameraUpdate = options.transformCameraUpdate;
this._snapToIntegerZoomOptions = options.snapToIntegerZoomOptions;

this._imageQueueHandle = ImageRequest.addThrottleControl(() => this.isMoving());

Expand Down Expand Up @@ -1008,6 +1020,11 @@ export class Map extends Camera {
} else throw new Error('maxZoom must be greater than the current minZoom');
}

setSnapToIntegerZoom(snap: SnapToIntegerZoomOptions): Map {
this._snapToIntegerZoomOptions = snap;
return this;
}

/**
* Returns the map's maximum allowable zoom level.
*
Expand All @@ -1019,6 +1036,21 @@ export class Map extends Camera {
*/
getMaxZoom(): number { return this.transform.maxZoom; }

shouldSnapToIntegerZoom(type: SnapToIntegerZoomType): boolean {
HarelM marked this conversation as resolved.
Show resolved Hide resolved
switch (type) {
case 'boxZoom':
return this._snapToIntegerZoomOptions.boxZoom;
case 'scrollZoom':
return this._snapToIntegerZoomOptions.scrollZoom;
case 'tapZoom':
return this._snapToIntegerZoomOptions.tapZoom;
case 'clickZoom':
return this._snapToIntegerZoomOptions.clickZoom;
default:
return false;
}
}

/**
* Sets or clears the map's minimum pitch.
* If the map's current pitch is lower than the new minimum,
Expand Down
2 changes: 1 addition & 1 deletion test/build/min.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('test min build', () => {

// Need to be very frugal when it comes to minified script.
// Most changes should increase less than 1k.
const increaseQuota = 1024;
const increaseQuota = 1600;
HarelM marked this conversation as resolved.
Show resolved Hide resolved

// decreasement means optimizations, so more generous (4k) but still
// need to make sure not a big bug that resulted in a big loss.
Expand Down