feat: minZoom and maxZoom
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -4458,6 +4458,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
* Constrains pan & zoom to a scene-coordinate box, so the viewport can't be
|
* Constrains pan & zoom to a scene-coordinate box, so the viewport can't be
|
||||||
* scrolled or zoomed out past it. Pass `null` to remove the constraint. When
|
* scrolled or zoomed out past it. Pass `null` to remove the constraint. When
|
||||||
* the box is smaller than the viewport, zoom is increased best-effort to fit.
|
* the box is smaller than the viewport, zoom is increased best-effort to fit.
|
||||||
|
*
|
||||||
|
* Optional `minZoom`/`maxZoom` take precedence over the box: `minZoom` lets
|
||||||
|
* the viewport zoom out past the fit zoom, while `maxZoom` caps zoom-in below
|
||||||
|
* the global limit.
|
||||||
*/
|
*/
|
||||||
setScrollConstraints = (scrollConstraints: ScrollConstraints | null) => {
|
setScrollConstraints = (scrollConstraints: ScrollConstraints | null) => {
|
||||||
// apply the constraint and clamp the viewport in a single, synchronously
|
// apply the constraint and clamp the viewport in a single, synchronously
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ const constrainScrollAxis = (
|
|||||||
* viewport may pan past the box edges (and zoom out below the fit zoom) until
|
* viewport may pan past the box edges (and zoom out below the fit zoom) until
|
||||||
* each edge has crossed the box by up to `tolerance` pixels on screen,
|
* each edge has crossed the box by up to `tolerance` pixels on screen,
|
||||||
* independent of the current zoom. Pass `0` (default) for a hard clamp.
|
* independent of the current zoom. Pass `0` (default) for a hard clamp.
|
||||||
|
*
|
||||||
|
* An explicit `minZoom`/`maxZoom` on the constraints takes precedence over the
|
||||||
|
* zoom the box would otherwise enforce: `minZoom` replaces the fit zoom (so the
|
||||||
|
* viewport may zoom out past the box) and `maxZoom` replaces the global
|
||||||
|
* `MAX_ZOOM` cap.
|
||||||
*/
|
*/
|
||||||
export const constrainScrollState = (
|
export const constrainScrollState = (
|
||||||
state: Pick<
|
state: Pick<
|
||||||
@@ -110,14 +115,20 @@ export const constrainScrollState = (
|
|||||||
|
|
||||||
tolerance = Math.max(tolerance, 0);
|
tolerance = Math.max(tolerance, 0);
|
||||||
|
|
||||||
// relax the min zoom so the user can briefly zoom out past the fit zoom,
|
// zoom bounds: an explicit min/maxZoom on the constraints takes precedence
|
||||||
// letting each viewport edge cross the box by up to `tolerance` screen px
|
// over the box-enforced fit zoom (lower) and the global MAX_ZOOM (upper).
|
||||||
const relaxedMinZoom = getMinZoomForConstraints(scrollConstraints, {
|
// Without a `minZoom`, relax the fit zoom by `tolerance` so the user can
|
||||||
width: width - 2 * tolerance,
|
// briefly zoom out past it (rubberbanding), letting each viewport edge cross
|
||||||
height: height - 2 * tolerance,
|
// the box by up to `tolerance` screen px.
|
||||||
});
|
const minZoom =
|
||||||
|
scrollConstraints.minZoom ??
|
||||||
|
getMinZoomForConstraints(scrollConstraints, {
|
||||||
|
width: width - 2 * tolerance,
|
||||||
|
height: height - 2 * tolerance,
|
||||||
|
});
|
||||||
|
const maxZoom = scrollConstraints.maxZoom ?? MAX_ZOOM;
|
||||||
const zoomValue = getNormalizedZoom(
|
const zoomValue = getNormalizedZoom(
|
||||||
clamp(state.zoom.value, relaxedMinZoom, MAX_ZOOM),
|
clamp(state.zoom.value, minZoom, maxZoom),
|
||||||
);
|
);
|
||||||
|
|
||||||
// `tolerance` screen px expressed in scene coords at the current zoom
|
// `tolerance` screen px expressed in scene coords at the current zoom
|
||||||
|
|||||||
@@ -219,6 +219,51 @@ describe("setScrollConstraints (integration)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explicit minZoom / maxZoom (pure)", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("lets minZoom override the box-enforced fit zoom (zoom out past the box)", () => {
|
||||||
|
// without minZoom the box forces a fit zoom of 0.2; minZoom 0.1 takes over
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clamps zoom out at minZoom", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.5 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets maxZoom cap zoom-in below the global MAX_ZOOM", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
|
||||||
|
scrollConstraints: { ...box, maxZoom: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an in-range zoom untouched", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(1.5) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.5, maxZoom: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("rubberband tolerance (pure)", () => {
|
describe("rubberband tolerance (pure)", () => {
|
||||||
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,21 @@ export type ScrollConstraints = {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
/**
|
||||||
|
* Pixel amount to allow to overscroll.
|
||||||
|
*/
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
|
/**
|
||||||
|
* Lower zoom bound. When set, it takes precedence over the zoom the box would
|
||||||
|
* otherwise enforce (the fit zoom that keeps the box covering the viewport),
|
||||||
|
* letting the viewport zoom out past the box.
|
||||||
|
*/
|
||||||
|
minZoom?: number;
|
||||||
|
/**
|
||||||
|
* Upper zoom bound. When set, it takes precedence over the global `MAX_ZOOM`,
|
||||||
|
* capping how far the viewport can zoom in.
|
||||||
|
*/
|
||||||
|
maxZoom?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
|||||||
Reference in New Issue
Block a user