Skip to content

Commit 7f6b95c

Browse files
YuChunTsaoHarelM
andauthored
fix: correct center calculation in flyTo when minZoom is set (#7743)
* fix: correct center calculation in flyTo when minZoom is set When minZoom causes zoom to be clamped by applyConstrain(), the theoretical scale from Van Wijk formula becomes inconsistent with tr.worldSize. This causes center to jump during the animation. Fix by computing actualScale from the clamped tr.zoom after setZoom, ensuring the scale and worldSize are derived from the same zoom value. * docs(changelog): add flyTo minZoom fix entry * fix: use stable worldSize in camera easing Save tr.worldSize to startWorldSize before calling setZoom and use it for project/unproject calculations. This prevents inconsistencies caused by tr.setZoom (and minZoom clamping) modifying tr.worldSize during the easing path computation, ensuring the center interpolation is based on a consistent world scale. * test: add flyTo center movement test with minZoom limit * fix: respect map.setMinZoom in flyTo rho calculation Previously, scaleOfMinZoom was only computed when minZoom was passed directly in flyTo options. If the user had called map.setMinZoom(X), tr.minZoom would be set but options.minZoom would be undefined, causing scaleOfMinZoom to remain unset and the rho recalculation in camera.ts to be skipped entirely. This meant that the flight path is not optimal when the user has set a minZoom level on the map. * fix: make flyTo minZoom a ceiling constraint, not a target The minZoom option in flyTo should act as a ceiling that prevents the flight arc from zooming out beyond the specified level, rather than forcing the arc to peak at that zoom level. * refactor: simplify minZoom constraint logic Remove unnecessary conditional branches and constants by always computing the effective minimum zoom. * test: refactor camera flyTo minZoom tests to sample all frames Replace nested setTimeout callbacks with a deterministic frame-by-frame sampling loop. This improves test reliability by capturing zoom values across the entire animation arc instead of relying on specific timing assumptions. Update assertions to use collected zoom values for more robust validation. * test: refactor flyTo frame timing tests with async/await Refactor nested setTimeout callbacks to async/await pattern for improved code clarity. * refactor: remove redundant scaleOfMinZoom type guard * test: extract animation frame simulation helper --------- Co-authored-by: Harel M <harel.mazor@gmail.com>
1 parent f2fc5e4 commit 7f6b95c

6 files changed

Lines changed: 136 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- _...Add new stuff here..._
44

55
### 🐞 Bug fixes
6+
- Fix camera jump in flyTo when minZoom is set ([#7743](https://github.com/maplibre/maplibre-gl-js/pull/7743)) (by [@YuChunTsao](https://github.com/YuChunTsao))
67
- _...Add new stuff here..._
78

89
## 6.0.0-19

src/geo/projection/camera_helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export type FlyToHandlerOptions = {
5858
export type FlyToHandlerResult = {
5959
easeFunc: (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => void;
6060
scaleOfZoom: number;
61-
scaleOfMinZoom?: number;
61+
scaleOfMinZoom: number;
6262
targetCenter: LngLat;
6363
pixelPathLength: number;
6464
};

src/geo/projection/mercator_camera_helper.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,26 +153,25 @@ export class MercatorCameraHelper implements ICameraHelper {
153153

154154
normalizeCenter(tr, targetCenter);
155155

156-
const from = projectToWorldCoordinates(tr.worldSize, options.locationAtOffset);
157-
const delta = projectToWorldCoordinates(tr.worldSize, targetCenter).sub(from);
156+
const startWorldSize = tr.worldSize;
157+
const from = projectToWorldCoordinates(startWorldSize, options.locationAtOffset);
158+
const delta = projectToWorldCoordinates(startWorldSize, targetCenter).sub(from);
158159

159160
const pixelPathLength = delta.mag();
160161

161162
const scaleOfZoom = zoomScale(targetZoom - startZoom);
162163

163-
const optionsMinZoom = typeof options.minZoom !== 'undefined';
164-
165-
let scaleOfMinZoom: number;
166-
167-
if (optionsMinZoom) {
168-
const minZoomPreConstrain = Math.min(+options.minZoom, startZoom, targetZoom);
169-
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
170-
scaleOfMinZoom = zoomScale(minZoom - startZoom);
171-
}
164+
const requestedMinZoom = typeof options.minZoom !== 'undefined' ? +options.minZoom : tr.minZoom;
165+
const effectiveMinZoom = Math.max(requestedMinZoom, tr.minZoom);
166+
const minZoomPreConstrain = Math.min(effectiveMinZoom, startZoom, targetZoom);
167+
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
168+
const scaleOfMinZoom = zoomScale(minZoom - startZoom);
172169

173170
const easeFunc = (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => {
174171
tr.setZoom(k === 1 ? targetZoom : startZoom + scaleZoom(scale));
175-
const newCenter = k === 1 ? targetCenter : unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(centerFactor)).mult(scale));
172+
const newCenter = k === 1
173+
? targetCenter
174+
: unprojectFromWorldCoordinates(startWorldSize, from.add(delta.mult(centerFactor)));
176175
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
177176
};
178177

src/geo/projection/vertical_perspective_camera_helper.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -361,18 +361,14 @@ export class VerticalPerspectiveCameraHelper implements ICameraHelper {
361361
const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0);
362362
const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom);
363363

364-
const optionsMinZoom = typeof options.minZoom === 'number';
365-
366-
let scaleOfMinZoom: number;
367-
368-
if (optionsMinZoom) {
369-
const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0);
370-
const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom);
371-
const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat);
372-
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
373-
const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0);
374-
scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom);
375-
}
364+
const requestedMinZoom = typeof options.minZoom === 'number' ? +options.minZoom : tr.minZoom;
365+
const effectiveMinZoom = Math.max(requestedMinZoom, tr.minZoom);
366+
const normalizedEffectiveMinZoom = effectiveMinZoom + getZoomAdjustment(targetCenter.lat, 0);
367+
const normalizedMinZoomPreConstrain = Math.min(normalizedEffectiveMinZoom, normalizedStartZoom, normalizedTargetZoom);
368+
const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat);
369+
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
370+
const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0);
371+
const scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom);
376372

377373
const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng);
378374
const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat);

src/ui/camera.test.ts

Lines changed: 105 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ function createCameraGlobeZoomed() {
8181
});
8282
}
8383

84+
async function simulateAllAnimationFrames(stub: ReturnType<typeof vi.spyOn>, camera: Camera & { simulateFrame: () => void }, duration: number) {
85+
for (let t = 1; t <= duration; t++) {
86+
await new Promise(resolve => setTimeout(resolve, 0));
87+
stub.mockImplementation(() => t);
88+
camera.simulateFrame();
89+
}
90+
}
91+
8492
describe('calculateCameraOptionsFromTo', () => {
8593
// Choose initial zoom to avoid center being constrained by mercator latitude limits.
8694
const camera = createCamera({zoom: 1});
@@ -1946,41 +1954,49 @@ describe('flyTo', () => {
19461954
expect(leftWorld0).toBeFalsy();
19471955
});
19481956

1949-
test('peaks at the specified zoom level', async () => {
1957+
test('flight arc does not zoom below the minZoom flyTo option', async () => {
19501958
const camera = createCamera({zoom: 20});
19511959
const stub = vi.spyOn(timeControl, 'now');
19521960

1953-
const minZoom = 1;
1954-
let zoomed = false;
1961+
const minZoom = 10;
1962+
const zoomValues: number[] = [];
1963+
const zoomSpy = vi.fn(() => zoomValues.push(camera.getZoom()));
1964+
camera.on('zoom', zoomSpy);
19551965

1956-
camera.on('zoom', () => {
1957-
const zoom = camera.getZoom();
1958-
if (zoom < 1) {
1959-
throw new Error(`${zoom} should be >= ${minZoom} during flyTo`);
1960-
}
1966+
const promise = camera.once('moveend');
19611967

1962-
if (camera.getZoom() < (minZoom + 1)) {
1963-
zoomed = true;
1964-
}
1965-
});
1968+
const duration = 10;
1969+
stub.mockImplementation(() => 0);
1970+
camera.flyTo({center: [1, 0], zoom: 20, minZoom, duration});
1971+
1972+
await simulateAllAnimationFrames(stub, camera, duration);
1973+
1974+
await promise;
1975+
expect(zoomSpy).toHaveBeenCalled();
1976+
expect(Math.min(...zoomValues)).toBeGreaterThanOrEqual(minZoom);
1977+
});
1978+
1979+
test('flight arc does not zoom below transform minZoom when only map setMinZoom is used', async () => {
1980+
const camera = createCamera({zoom: 20});
1981+
camera.transform.setMinZoom(10);
1982+
const stub = vi.spyOn(timeControl, 'now');
1983+
1984+
const mapMinZoom = 10;
1985+
const zoomValues: number[] = [];
1986+
const zoomSpy = vi.fn(() => zoomValues.push(camera.getZoom()));
1987+
camera.on('zoom', zoomSpy);
19661988

19671989
const promise = camera.once('moveend');
19681990

1991+
const duration = 10;
19691992
stub.mockImplementation(() => 0);
1970-
camera.flyTo({center: [1, 0], zoom: 20, minZoom, duration: 10});
1993+
camera.flyTo({center: [1, 0], zoom: 20, duration});
19711994

1972-
setTimeout(() => {
1973-
stub.mockImplementation(() => 3);
1974-
camera.simulateFrame();
1975-
1976-
setTimeout(() => {
1977-
stub.mockImplementation(() => 10);
1978-
camera.simulateFrame();
1979-
}, 0);
1980-
}, 0);
1995+
await simulateAllAnimationFrames(stub, camera, duration);
19811996

19821997
await promise;
1983-
expect(zoomed).toBeTruthy();
1998+
expect(zoomSpy).toHaveBeenCalled();
1999+
expect(Math.min(...zoomValues)).toBeGreaterThanOrEqual(mapMinZoom);
19842000
});
19852001

19862002
test('respects transform\'s maxZoom', async () => {
@@ -2033,6 +2049,41 @@ describe('flyTo', () => {
20332049
expect(lat).toBeCloseTo(34);
20342050
});
20352051

2052+
test('center moves toward target at intermediate frames when transform minZoom limits zoom', async () => {
2053+
const transform = new MercatorTransform({minZoom: 10, maxZoom: 20, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
2054+
transform.resize(512, 512);
2055+
2056+
const camera = attachSimulateFrame(new CameraMock(transform, new MercatorCameraHelper(), {} as any));
2057+
camera._update = () => {};
2058+
camera.jumpTo({center: [0, 0], zoom: 10});
2059+
2060+
let midLng: number | undefined;
2061+
camera.on('move', () => {
2062+
if (midLng === undefined) midLng = camera.getCenter().lng;
2063+
});
2064+
2065+
const promise = camera.once('moveend');
2066+
const stub = vi.spyOn(timeControl, 'now');
2067+
stub.mockImplementation(() => 0);
2068+
camera.flyTo({center: [40, 0], zoom: 10, duration: 10});
2069+
2070+
await new Promise(resolve => setTimeout(resolve, 0));
2071+
stub.mockImplementation(() => 5);
2072+
camera.simulateFrame();
2073+
2074+
await new Promise(resolve => setTimeout(resolve, 0));
2075+
stub.mockImplementation(() => 10);
2076+
camera.simulateFrame();
2077+
2078+
await promise;
2079+
2080+
const startLng = 0;
2081+
const targetLng = 40;
2082+
expect(midLng).toBeGreaterThan((startLng + targetLng) / 2);
2083+
expect(midLng).toBeLessThanOrEqual(targetLng);
2084+
expect(camera.getCenter().lng).toBeCloseTo(targetLng);
2085+
});
2086+
20362087
test('resets duration to 0 if it exceeds maxDuration', async () => {
20372088
let startTime: number;
20382089
const camera = createCamera({center: [37.63454, 55.75868], zoom: 18});
@@ -3803,43 +3854,49 @@ describe('flyTo globe projection', () => {
38033854
expect(leftWorld0).toBeFalsy();
38043855
});
38053856

3806-
test('peaks at the specified zoom level', async () => {
3857+
test('flight arc does not zoom below the minZoom flyTo option (globe projection)', async () => {
38073858
const camera = createCameraGlobe({zoom: 20});
38083859
const stub = vi.spyOn(timeControl, 'now');
38093860

3810-
const minZoom = 1;
3811-
let zoomed = false;
3861+
const minZoom = 10;
3862+
const zoomValues: number[] = [];
3863+
const zoomSpy = vi.fn(() => zoomValues.push(camera.getZoom()));
3864+
camera.on('zoom', zoomSpy);
38123865

3813-
let leastZoom = 200;
3814-
camera.on('zoom', () => {
3815-
const zoom = camera.getZoom();
3816-
if (zoom < 1) {
3817-
throw new Error(`${zoom} should be >= ${minZoom} during flyTo`);
3818-
}
3866+
const promise = camera.once('moveend');
38193867

3820-
leastZoom = Math.min(leastZoom, zoom);
3821-
if (zoom < (minZoom + 1)) {
3822-
zoomed = true;
3823-
}
3824-
});
3868+
const duration = 10;
3869+
stub.mockImplementation(() => 0);
3870+
camera.flyTo({center: [1, 0], zoom: 20, minZoom, duration});
3871+
3872+
await simulateAllAnimationFrames(stub, camera, duration);
3873+
3874+
await promise;
3875+
expect(zoomSpy).toHaveBeenCalled();
3876+
expect(Math.min(...zoomValues)).toBeGreaterThanOrEqual(minZoom);
3877+
});
3878+
3879+
test('flight arc does not zoom below transform minZoom when only map setMinZoom is used (globe projection)', async () => {
3880+
const camera = createCameraGlobe({zoom: 20});
3881+
camera.transform.setMinZoom(10);
3882+
const stub = vi.spyOn(timeControl, 'now');
3883+
3884+
const mapMinZoom = 10;
3885+
const zoomValues: number[] = [];
3886+
const zoomSpy = vi.fn(() => zoomValues.push(camera.getZoom()));
3887+
camera.on('zoom', zoomSpy);
38253888

38263889
const promise = camera.once('moveend');
38273890

3891+
const duration = 10;
38283892
stub.mockImplementation(() => 0);
3829-
camera.flyTo({center: [1, 0], zoom: 20, minZoom, duration: 10});
3893+
camera.flyTo({center: [1, 0], zoom: 20, duration});
38303894

3831-
setTimeout(() => {
3832-
stub.mockImplementation(() => 3);
3833-
camera.simulateFrame();
3834-
3835-
setTimeout(() => {
3836-
stub.mockImplementation(() => 10);
3837-
camera.simulateFrame();
3838-
}, 0);
3839-
}, 0);
3895+
await simulateAllAnimationFrames(stub, camera, duration);
38403896

38413897
await promise;
3842-
expect(zoomed).toBeTruthy();
3898+
expect(zoomSpy).toHaveBeenCalled();
3899+
expect(Math.min(...zoomValues)).toBeGreaterThanOrEqual(mapMinZoom);
38433900
});
38443901

38453902
test('respects transform\'s maxZoom', async () => {

src/ui/camera.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ export type FlyToOptions = AnimationOptions & CameraOptions & {
136136
*/
137137
curve?: number;
138138
/**
139-
* The zero-based zoom level at the peak of the flight path. If
140-
* `options.curve` is specified, this option is ignored.
139+
* The minimum zoom level that the flight arc may reach. The animation will
140+
* not zoom out beyond this level. If the natural flight arc stays within
141+
* this boundary, the arc is unchanged. This acts as a ceiling on zoom-out
142+
* even when `options.curve` is also specified.
141143
*/
142144
minZoom?: number;
143145
/**
@@ -1485,12 +1487,12 @@ export abstract class Camera extends Evented<MapEventType> {
14851487
// the world image origin at the initial scale.
14861488
const u1 = flyToHandler.pixelPathLength;
14871489

1488-
if (typeof flyToHandler.scaleOfMinZoom === 'number') {
1489-
// w<sub>m</sub>: Maximum visible span, measured in pixels with respect to the initial
1490-
// scale.
1491-
const wMax = w0 / flyToHandler.scaleOfMinZoom;
1492-
rho = Math.sqrt(wMax / u1 * 2);
1493-
}
1490+
// w<sub>m</sub>: Maximum visible span, measured in pixels with respect to the initial
1491+
// scale.
1492+
const wMax = w0 / flyToHandler.scaleOfMinZoom;
1493+
// Only reduce rho (limit zoom-out). If the natural arc stays within the minZoom
1494+
// boundary, preserve the default rho rather than forcing the arc deeper.
1495+
rho = Math.min(rho, Math.sqrt(wMax / u1 * 2));
14941496

14951497
// ρ²
14961498
const rho2 = rho * rho;

0 commit comments

Comments
 (0)