Skip to content

Commit c0dcee7

Browse files
authored
Fix easeTo interpolation on pitched maps (#4540)
* clean up easeTo/flyTo a bit * fix position interpolation in easeTo * a different approach to easeTo interpolation * fix easeTo easing for zooming out and panning * one more zoom out easing correction * another take at easeTo interpolation * fix offset handling in easing methods, fix tests
1 parent c9240a1 commit c0dcee7

File tree

2 files changed

+82
-90
lines changed

2 files changed

+82
-90
lines changed

src/ui/camera.js

+81-89
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const Evented = require('../util/evented');
3131
* @property {number} duration The animation's duration, measured in milliseconds.
3232
* @property {Function} easing A function taking a time in the range 0..1 and returning a number where 0 is
3333
* the initial state and 1 is the final state.
34-
* @property {PointLike} offset `x` and `y` coordinates representing the animation's origin of movement relative to the map's center.
34+
* @property {PointLike} offset of the target center relative to real map container center at the end of animation.
3535
* @property {boolean} animate If `false`, no animation will occur.
3636
*/
3737

@@ -77,8 +77,7 @@ class Camera extends Evented {
7777
* @see [Move symbol with the keyboard](https://www.mapbox.com/mapbox-gl-js/example/rotating-controllable-marker/)
7878
*/
7979
setCenter(center, eventData) {
80-
this.jumpTo({center: center}, eventData);
81-
return this;
80+
return this.jumpTo({center: center}, eventData);
8281
}
8382

8483
/**
@@ -94,9 +93,8 @@ class Camera extends Evented {
9493
* @see [Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/)
9594
*/
9695
panBy(offset, options, eventData) {
97-
this.panTo(this.transform.center,
98-
util.extend({offset: Point.convert(offset).mult(-1)}, options), eventData);
99-
return this;
96+
offset = Point.convert(offset).mult(-1);
97+
return this.panTo(this.transform.center, util.extend({offset}, options), eventData);
10098
}
10199

102100
/**
@@ -496,8 +494,13 @@ class Camera extends Evented {
496494
easing: util.ease
497495
}, options);
498496

497+
if (options.animate === false) options.duration = 0;
498+
499+
if (options.smoothEasing && options.duration !== 0) {
500+
options.easing = this._smoothOutEasing(options.duration);
501+
}
502+
499503
const tr = this.transform,
500-
offset = Point.convert(options.offset),
501504
startZoom = this.getZoom(),
502505
startBearing = this.getBearing(),
503506
startPitch = this.getPitch(),
@@ -506,73 +509,56 @@ class Camera extends Evented {
506509
bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing,
507510
pitch = 'pitch' in options ? +options.pitch : startPitch;
508511

509-
let toLngLat,
510-
toPoint;
512+
const pointAtOffset = tr.centerPoint.add(Point.convert(options.offset));
513+
const locationAtOffset = tr.pointLocation(pointAtOffset);
514+
const center = LngLat.convert(options.center || locationAtOffset);
515+
const from = tr.project(locationAtOffset);
516+
const delta = tr.project(center).sub(from);
517+
const finalScale = tr.zoomScale(zoom - startZoom);
511518

512-
if ('center' in options) {
513-
toLngLat = LngLat.convert(options.center);
514-
toPoint = tr.centerPoint.add(offset);
515-
} else if ('around' in options) {
516-
toLngLat = LngLat.convert(options.around);
517-
toPoint = tr.locationPoint(toLngLat);
518-
} else {
519-
toPoint = tr.centerPoint.add(offset);
520-
toLngLat = tr.pointLocation(toPoint);
521-
}
522-
523-
const fromPoint = tr.locationPoint(toLngLat);
519+
let around, aroundPoint;
524520

525-
if (options.animate === false) options.duration = 0;
521+
if (options.around) {
522+
around = LngLat.convert(options.around);
523+
aroundPoint = tr.locationPoint(around);
524+
}
526525

527526
this.zooming = (zoom !== startZoom);
528527
this.rotating = (startBearing !== bearing);
529528
this.pitching = (pitch !== startPitch);
530529

531-
if (options.smoothEasing && options.duration !== 0) {
532-
options.easing = this._smoothOutEasing(options.duration);
533-
}
534-
535-
if (!options.noMoveStart) {
536-
this.moving = true;
537-
this.fire('movestart', eventData);
538-
}
539-
if (this.zooming) {
540-
this.fire('zoomstart', eventData);
541-
}
542-
if (this.pitching) {
543-
this.fire('pitchstart', eventData);
544-
}
530+
this._prepareEase(eventData, options.noMoveStart);
545531

546532
clearTimeout(this._onEaseEnd);
547533

548534
this._ease(function (k) {
549535
if (this.zooming) {
550536
tr.zoom = interpolate(startZoom, zoom, k);
551537
}
552-
553538
if (this.rotating) {
554539
tr.bearing = interpolate(startBearing, bearing, k);
555540
}
556-
557541
if (this.pitching) {
558542
tr.pitch = interpolate(startPitch, pitch, k);
559543
}
560544

561-
tr.setLocationAtPoint(toLngLat, fromPoint.add(toPoint.sub(fromPoint)._mult(k)));
562-
563-
this.fire('move', eventData);
564-
if (this.zooming) {
565-
this.fire('zoom', eventData);
566-
}
567-
if (this.rotating) {
568-
this.fire('rotate', eventData);
569-
}
570-
if (this.pitching) {
571-
this.fire('pitch', eventData);
545+
if (around) {
546+
tr.setLocationAtPoint(around, aroundPoint);
547+
} else {
548+
const scale = tr.zoomScale(tr.zoom - startZoom);
549+
const base = zoom > startZoom ?
550+
Math.min(2, finalScale) :
551+
Math.max(0.5, finalScale);
552+
const speedup = Math.pow(base, 1 - k);
553+
const newCenter = tr.unproject(from.add(delta.mult(k * speedup)).mult(scale));
554+
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
572555
}
556+
557+
this._fireMoveEvents(eventData);
558+
573559
}, () => {
574560
if (options.delayEndEvents) {
575-
this._onEaseEnd = setTimeout(this._easeToEnd.bind(this, eventData), options.delayEndEvents);
561+
this._onEaseEnd = setTimeout(() => this._easeToEnd(eventData), options.delayEndEvents);
576562
} else {
577563
this._easeToEnd(eventData);
578564
}
@@ -581,6 +567,33 @@ class Camera extends Evented {
581567
return this;
582568
}
583569

570+
_prepareEase(eventData, noMoveStart) {
571+
this.moving = true;
572+
573+
if (!noMoveStart) {
574+
this.fire('movestart', eventData);
575+
}
576+
if (this.zooming) {
577+
this.fire('zoomstart', eventData);
578+
}
579+
if (this.pitching) {
580+
this.fire('pitchstart', eventData);
581+
}
582+
}
583+
584+
_fireMoveEvents(eventData) {
585+
this.fire('move', eventData);
586+
if (this.zooming) {
587+
this.fire('zoom', eventData);
588+
}
589+
if (this.rotating) {
590+
this.fire('rotate', eventData);
591+
}
592+
if (this.pitching) {
593+
this.fire('pitch', eventData);
594+
}
595+
}
596+
584597
_easeToEnd(eventData) {
585598
const wasZooming = this.zooming;
586599
const wasPitching = this.pitching;
@@ -596,7 +609,6 @@ class Camera extends Evented {
596609
this.fire('pitchend', eventData);
597610
}
598611
this.fire('moveend', eventData);
599-
600612
}
601613

602614
/**
@@ -671,16 +683,19 @@ class Camera extends Evented {
671683
}, options);
672684

673685
const tr = this.transform,
674-
offset = Point.convert(options.offset),
675686
startZoom = this.getZoom(),
676687
startBearing = this.getBearing(),
677688
startPitch = this.getPitch();
678689

679-
const center = 'center' in options ? LngLat.convert(options.center) : this.getCenter();
680690
const zoom = 'zoom' in options ? +options.zoom : startZoom;
681691
const bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing;
682692
const pitch = 'pitch' in options ? +options.pitch : startPitch;
683693

694+
const scale = tr.zoomScale(zoom - startZoom);
695+
const pointAtOffset = tr.centerPoint.add(Point.convert(options.offset));
696+
const locationAtOffset = tr.pointLocation(pointAtOffset);
697+
const center = LngLat.convert(options.center || locationAtOffset);
698+
684699
// If a path crossing the antimeridian would be shorter, extend the final coordinate so that
685700
// interpolating between the two endpoints will cross it.
686701
if (tr.renderWorldCopies && Math.abs(tr.center.lng) + Math.abs(center.lng) > 180) {
@@ -691,9 +706,8 @@ class Camera extends Evented {
691706
}
692707
}
693708

694-
const scale = tr.zoomScale(zoom - startZoom),
695-
from = tr.point,
696-
to = 'center' in options ? tr.project(center).sub(offset.div(scale)) : from;
709+
const from = tr.project(locationAtOffset);
710+
const delta = tr.project(center).sub(from);
697711

698712
let rho = options.curve;
699713

@@ -703,7 +717,7 @@ class Camera extends Evented {
703717
w1 = w0 / scale,
704718
// Length of the flight path as projected onto the ground plane, measured in pixels from
705719
// the world image origin at the initial scale.
706-
u1 = to.sub(from).mag();
720+
u1 = delta.mag();
707721

708722
if ('minZoom' in options) {
709723
const minZoom = util.clamp(Math.min(options.minZoom, startZoom, zoom), tr.minZoom, tr.maxZoom);
@@ -769,26 +783,17 @@ class Camera extends Evented {
769783
options.duration = 1000 * S / V;
770784
}
771785

772-
this.moving = true;
773786
this.zooming = true;
774-
if (startBearing !== bearing) this.rotating = true;
775-
if (startPitch !== pitch) this.pitching = true;
787+
this.rotating = (startBearing !== bearing);
788+
this.pitching = (pitch !== startPitch);
776789

777-
this.fire('movestart', eventData);
778-
this.fire('zoomstart', eventData);
779-
if (this.pitching) this.fire('pitchstart', eventData);
790+
this._prepareEase(eventData, false);
780791

781792
this._ease(function (k) {
782793
// s: The distance traveled along the flight path, measured in ρ-screenfuls.
783-
const s = k * S,
784-
us = u(s);
785-
794+
const s = k * S;
786795
const scale = 1 / w(s);
787796
tr.zoom = startZoom + tr.scaleZoom(scale);
788-
tr.center = tr.unproject(from.add(to.sub(from).mult(us)).mult(scale));
789-
if (tr.renderWorldCopies) {
790-
tr.center = tr.center.wrap();
791-
}
792797

793798
if (this.rotating) {
794799
tr.bearing = interpolate(startBearing, bearing, k);
@@ -797,25 +802,12 @@ class Camera extends Evented {
797802
tr.pitch = interpolate(startPitch, pitch, k);
798803
}
799804

800-
this.fire('move', eventData);
801-
this.fire('zoom', eventData);
802-
if (this.rotating) {
803-
this.fire('rotate', eventData);
804-
}
805-
if (this.pitching) {
806-
this.fire('pitch', eventData);
807-
}
808-
}, function() {
809-
const wasPitching = this.pitching;
810-
this.moving = false;
811-
this.zooming = false;
812-
this.rotating = false;
813-
this.pitching = false;
814-
815-
if (wasPitching) this.fire('pitchend', eventData);
816-
this.fire('zoomend', eventData);
817-
this.fire('moveend', eventData);
818-
}, options);
805+
const newCenter = tr.unproject(from.add(delta.mult(u(s))).mult(scale));
806+
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
807+
808+
this._fireMoveEvents(eventData);
809+
810+
}, () => this._easeToEnd(eventData), options);
819811

820812
return this;
821813
}

test/unit/ui/camera.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,7 @@ test('camera', (t) => {
10531053
let crossedAntimeridian;
10541054

10551055
camera.on('move', () => {
1056-
if (camera.getCenter().lng < -170) {
1056+
if (fixedLngLat(camera.getCenter(), 10).lng < -170) {
10571057
crossedAntimeridian = true;
10581058
}
10591059
});

0 commit comments

Comments
 (0)