Skip to content

Commit 1503fc8

Browse files
committed
added(display): introduce [FollowPathAnimationController](https://heremaps.github.io/xyz-maps/docs/classes/display.followpathanimationcontroller.html) for smooth, fluid path-following camera animations
Signed-off-by: Tim Deubler <tim.deubler@here.com>
1 parent ddb9cec commit 1503fc8

File tree

12 files changed

+432
-30
lines changed

12 files changed

+432
-30
lines changed

packages/common/src/geotools.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,15 @@ let UNDEF;
2828
export type Point = number[] | [number, number] | [number, number, number];
2929
type BBox = [number, number, number, number];
3030

31-
export const calcBearing = (c1: Point, c2: Point) => {
32-
let r;
33-
let l1;
34-
let l2;
35-
let l3;
36-
let l4;
37-
let dr;
38-
r = Math.PI / 180;
39-
l1 = c1[1] * r;
40-
l2 = c2[1] * r;
41-
l3 = c1[0] * r;
42-
l4 = c2[0] * r;
43-
dr = Math.atan2(
44-
Math.cos(l1) * Math.sin(l2) - Math.sin(l1) * Math.cos(l2) * Math.cos(l4 - l3),
45-
Math.sin(l4 - l3) * Math.cos(l2)
46-
);
47-
return (dr / r + 360) % 360;
31+
export const calcBearing = (c1: Point, c2: Point): number => {
32+
const lat1 = c1[1] * TORAD;
33+
const lat2 = c2[1] * TORAD;
34+
const dLon = (c2[0] - c1[0]) * TORAD;
35+
const y = Math.sin(dLon) * Math.cos(lat2);
36+
const x = Math.cos(lat1) * Math.sin(lat2) -
37+
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
38+
const bearing = Math.atan2(y, x) * TODEG;
39+
return (bearing + 360) % 360;
4840
};
4941

5042
// based on www.movable-type.co.uk/scripts/latlong.html
@@ -102,6 +94,7 @@ export const extendBBox = (bbox: BBox, distanceMeter: number): GeoJSONBBox => {
10294
];
10395
};
10496

97+
// Haversine
10598
export const distance = (p1: Point, p2: Point) => {
10699
const dLat = TORAD * (p2[1] - p1[1]);
107100
const dLng = TORAD * (p2[0] - p1[0]);

packages/display/src/Map.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,13 +1160,23 @@ export class Map {
11601160
* @param {number} [duration=0] - Optional zoom animation duration in milliseconds.
11611161
*/
11621162
setAltitude(targetAltitude: number, duration: number = 0) {
1163-
const currentZoom = this.getZoomlevel();
11641163
const camPosition = this.getCamera().position;
1165-
const zoom = currentZoom + Math.log2(camPosition.altitude / targetAltitude);
1164+
const zoom = this._altToZoom(targetAltitude);
11661165
const camGroundScreen = this.geoToPixel(camPosition.longitude, camPosition.latitude, 0);
11671166
this.setZoomlevel(zoom, camGroundScreen.x, camGroundScreen.y, duration);
11681167
}
11691168

1169+
/**
1170+
* helper function
1171+
* @hidden
1172+
* @internal
1173+
*/
1174+
_altToZoom(altitude: number): number {
1175+
const currentZoom = this.getZoomlevel();
1176+
const camPosition = this.getCamera().position;
1177+
return currentZoom + Math.log2(camPosition.altitude / altitude);
1178+
}
1179+
11701180
/**
11711181
* Set new geographical center for the map.
11721182
*
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright (C) 2019-2025 HERE Europe B.V.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
import {Easing, linear} from './Easings';
21+
22+
export interface AnimationOptions {
23+
duration?: number; // Total animation duration in ms
24+
loop?: boolean; // Whether to loop after finishing
25+
easing?: Easing; // Easing function
26+
}
27+
28+
/**
29+
* Abstract base class for animation controllers.
30+
*
31+
* Manages timing, easing, looping, and basic control functions such as start, pause, resume, and cancel.
32+
* This generic controller can be extended to support various animation types
33+
* (e\.g\., for paths, markers, or camera movements).
34+
*
35+
* The actual animation logic should be implemented in the `updateFrame\(t\)` method by subclasses.
36+
*/
37+
export class AnimationController {
38+
protected running = false;
39+
protected paused = false;
40+
protected pauseOffset = 0;
41+
protected startTime = 0;
42+
protected rafId: number | null = null;
43+
protected onCompleteCb?: () => void;
44+
45+
protected duration: number;
46+
protected loop: boolean;
47+
protected easing: Easing;
48+
private pauseOffsetStart: DOMHighResTimeStamp;
49+
50+
constructor(options: AnimationOptions = {}) {
51+
this.duration = options.duration ?? 1000;
52+
this.loop = options.loop ?? false;
53+
this.easing = options.easing ?? linear;
54+
}
55+
56+
/**
57+
* Start or restart the animation.
58+
*/
59+
start(): void {
60+
this.running = true;
61+
this.paused = false;
62+
this.pauseOffset = 0;
63+
this.startTime = performance.now();
64+
this.rafId = requestAnimationFrame(this.frame.bind(this));
65+
}
66+
67+
/**
68+
* Pause the animation.
69+
*/
70+
pause(): void {
71+
if (!this.running || this.paused) return;
72+
this.paused = true;
73+
this.pauseOffsetStart = performance.now();
74+
}
75+
76+
/**
77+
* Resume a paused animation.
78+
*/
79+
resume(): void {
80+
if (!this.running || !this.paused) return;
81+
this.paused = false;
82+
if (this.pauseOffsetStart != null) {
83+
this.pauseOffset += performance.now() - this.pauseOffsetStart;
84+
this.pauseOffsetStart = undefined;
85+
}
86+
}
87+
88+
/**
89+
* Cancel the animation.
90+
*/
91+
cancel(): void {
92+
this.running = false;
93+
if (this.rafId != null) cancelAnimationFrame(this.rafId);
94+
}
95+
96+
/**
97+
* Check if the animation is running.
98+
*/
99+
isRunning(): boolean {
100+
return this.running && !this.paused;
101+
}
102+
103+
/**
104+
* Set a callback to be invoked when the animation completes.
105+
*/
106+
onComplete(cb: () => void) {
107+
this.onCompleteCb = cb;
108+
}
109+
110+
/**
111+
* Returns a promise that resolves when the animation completes.
112+
*/
113+
complete(): Promise<void> {
114+
return new Promise((resolve) => {
115+
this.onComplete(() => resolve());
116+
});
117+
}
118+
119+
/**
120+
* Internal frame handler.
121+
* Calls updateFrame(t) which should be implemented by subclasses.
122+
*/
123+
protected frame(now: number) {
124+
if (!this.running) return;
125+
if (this.paused) {
126+
this.rafId = requestAnimationFrame(this.frame.bind(this));
127+
return;
128+
}
129+
130+
const elapsed = now - this.startTime - this.pauseOffset;
131+
let t = Math.max(0, Math.min(1, elapsed / this.duration));
132+
t = this.easing(t);
133+
134+
this.updateFrame(t);
135+
136+
if (elapsed >= this.duration) {
137+
if (this.loop) {
138+
this.startTime = performance.now();
139+
this.pauseOffset = 0;
140+
this.rafId = requestAnimationFrame(this.frame.bind(this));
141+
} else {
142+
this.running = false;
143+
this.onCompleteCb?.();
144+
}
145+
return;
146+
}
147+
148+
this.rafId = requestAnimationFrame(this.frame.bind(this));
149+
}
150+
151+
/**
152+
* Override this in subclasses to implement animation logic.
153+
* @param t normalized time [0..1] after easing
154+
*/
155+
protected updateFrame(t: number): void {
156+
}
157+
}

packages/display/src/animation/Easings.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@
1717
* License-Filename: LICENSE
1818
*/
1919

20-
export const linear = (t: number): number => {
20+
21+
export type Easing = (t: number) => number;
22+
23+
export const linear: Easing = (t: number): number => {
2124
return t;
2225
};
2326

24-
export const easeOut = (t: number): number => {
27+
export const easeOut: Easing = (t: number): number => {
2528
return 1 - Math.pow(1 - t, 1.5);
2629
};
2730

28-
export const easeOutSine = (t: number): number => {
31+
export const easeOutSine: Easing = (t: number): number => {
2932
return Math.sin((Math.PI * t) * .5);
3033
};
3134

32-
export const easeOutCubic = (t: number): number => {
35+
export const easeOutCubic: Easing = (t: number): number => {
3336
t = 1 - t;
3437
return 1 - t * t * t;
3538
};

0 commit comments

Comments
 (0)