diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b775d6..acf5dd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## 3.10.0 +### ✨ Features and improvements +- Adds the new `MapTilerAnimation` module and associated helpers for creating and managing animations, lerping between values and 'smoothing' arrays. +- Adds the new `AnimatedRouteLayer` module and associated helpers for animating camera movement along GeoJSON features paths. + +### πŸ› Bug Fixes +- None +### βš™οΈ Others +- None + +## 3.10.0 + ### ✨ Features and improvements - MapLibre GL dependency was updated to `5.14` - Types that were removed in a minor version of MapLibre GL, breaking semver, were moved into MapTiler SDK to not break compatibility for MapTiler SDK users diff --git a/README.md b/README.md index 78a183f3..32e25718 100644 --- a/README.md +++ b/README.md @@ -1323,7 +1323,158 @@ When defining a new _ramp_, the colors can be an RGB array (`[number, number, nu Many methods are available on color ramps, such as getting the `` element of it, rescaling it, flipping it or [resampling it in a non-linear way](colorramp.md). Read more on [our reference page](https://docs.maptiler.com/sdk-js/api/color-ramp/) and have a look at our [examples](https://docs.maptiler.com/sdk-js/examples/?q=colorramp) to see how they work. -### Vector Layer Helpers +# Camera routes and animations + +The SDK comes with several classes to help with animations, particularly route animations. + +See `demos/11-animated-routes.html` for examples. +See `demos/12-maptiler-animation.html` for examples. + +#### 🧩 `MaptilerAnimation` + +MaptilerAnimation is a utility class for smoothly animating between keyframes using custom easing and playback control. It supports event-based hooks for frame updates and completion, and works well within rendering loops or UI transitions. + +##### πŸš€ Usage + +```ts +// linearly animated between values +const animation = new MaptilerAnimation({ + keyframes: [ + // `props` are interpolated across the duration + { delta: 0, props: { lon: -7.445, } }, + // `userData` can hold any type of custom data to pass with the keyframe + { delta: 0.5, userData: { mydata: "whoa!" } }, + { delta: 1, props: { lon: -7.473 } } + ], + duration: 1000, // 1 second + iterations: Infinity // loop forever +}); + +const marker = new Marker().setLngLat( + new LngLat( + -7.449346225791231, + 39.399728941536836, + ) +).addTo(map); + +// TimeUpdate is fired every frame +animation.addEventListener(AnimationEventTypes.TimeUpdate, (e) => { + marker.setLngLat( + new LngLat( + e.props.lon, + 39.399728941536836, + ) + ) +}) +// fired when the keyframe changes +animation.addEventListener(AnimationEventTypes.Keyframe, ({ userData }) => { + console.log(userData.mydata) // "whoa!" +}); + +animation.play(); +``` +![](images/animate-linear-trimmed.gif) + +```ts +// eased between values +const animation = new MaptilerAnimation({ + keyframes: [ + // `props` are interpolated across the duration + { delta: 0, easing: EasingFunctionName.ElasticInOut, props: { lon: -7.445, } }, + { delta: 1, props: { lon: -7.455 } } + ], + duration: 1000, // 1 second + iterations: Infinity // loop forever +}); +``` +![](images/animate-elastic-trimmed.gif) + +#### Β πŸ—ΊοΈ `AnimatedRouteLayer` + +`AnimatedRouteLayer` is custom layer that animates a path or route on the map based on keyframes or GeoJSON data. It supports animated line styling and camera following, making it ideal for visualizing routes, playback tracks, or timeline-based geographic events. + +Note: At present, to avoid problems arising from the camera being manipulated by two animations at any one time, there can only ever be one instance of `AnimatedRouteLayer` on the map at any time. This API may change in the future, but at present you must remove each instance of `AnimatedRouteLayer` from the map before adding another. + +##### ✨ Features + - Animate a path using keyframes or GeoJSON data + - Optional animated stroke styles to indicate progress + - Camera movement smoothing, following along the route + - Configurable duration, easing, delay, and iterations via geojson properties + - Event-based lifecycle hooks for adaptability. + - Optional manual frame advancement (e.g., for scrubbing or syncing with map events, scroll etc etc) + +##### πŸš€ Basic Usage +```ts +const myGeoJSONSource = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [-74.0060, 40.7128], + [-73.9352, 40.7306], + [-73.9851, 40.7580] + ] + }, + "properties": { + "@duration": 5000, // animation params are prepended with '@' + "@iterations": 3, + "@delay": 1000, + "@autoplay": true, + "bearing": [ + 40, + 30, + 10, + 10, + 20, + 40, + ] + } + } + ] +} + +map.addSource("my-geojson-source", { + type: "geojson", + data: myGeoJSONSource, +}); + +const animatedRoute = new AnimatedRouteLayer({ + source: { + // assumes that the source is already added to the map with the given layer ID + id: "my-geojson-source", // the name of the source + layerID: "route-layer", // the name of the layer + }, + // OR + keyframes: [], // an array of keyframes + + duration: 5000, + pathStrokeAnimation: { + // will only be applied to LineString GeoJSON types + activeColor: [0, 128, 0, 1], // color of the line that has already been traversed + inactiveColor: [128, 128, 128, 0.5], + }, + cameraAnimation: { + follow: true, // should the camera follow the route? + pathSmoothing: { + resolution: 20, // the resolution of the smoothness + epsilon: 10, // how much the path is simplified before smoothing + }, + }, + autoplay: true, +}); + +// Add to map +map.addLayer(animatedRoute); + +// Playback controls +animatedRoute.play(); +animatedRoute.pause(); +``` + +For a full example of how to use this, look at [the example](./demos/11-animated-routes.html) **Let's make vector layers easy!** Originally, you'd have to add a source and then proceed to the styling of your layer, which can be tricky because there are a lot of `paint` and `layout` options and they vary a lot from one type of layer to another. **But we have helpers for this!** πŸ–‹οΈ ![](images/screenshots/point-layer.jpg) diff --git a/demos/public/11-animated-routes.html b/demos/public/11-animated-routes.html new file mode 100644 index 00000000..e4dca655 --- /dev/null +++ b/demos/public/11-animated-routes.html @@ -0,0 +1,79 @@ + + + + + + Animations and Animated Routes + + + +
+
+ + + + +
1.0x
+
+ +
+ + + \ No newline at end of file diff --git a/demos/public/12-maptiler-animation.html b/demos/public/12-maptiler-animation.html new file mode 100644 index 00000000..a6b2de14 --- /dev/null +++ b/demos/public/12-maptiler-animation.html @@ -0,0 +1,83 @@ + + + + + + MaptilerAnimation with MapTiler 3D Module + + + + +
+
+ + + + + +
1.0x
+
+ +
+ + + \ No newline at end of file diff --git a/demos/public/index.html b/demos/public/index.html index 87e0c872..ceab72c2 100644 --- a/demos/public/index.html +++ b/demos/public/index.html @@ -61,6 +61,8 @@ SpaceBox β†’ Custom Controls: Declarative β†’ Custom Controls: Programmatic β†’ + Animated Routes β†’ + Basic Animations β†’ diff --git a/demos/public/models/burj_khalifa/license.txt b/demos/public/models/burj_khalifa/license.txt new file mode 100644 index 00000000..548492d2 --- /dev/null +++ b/demos/public/models/burj_khalifa/license.txt @@ -0,0 +1,11 @@ +Model Information: +* title: Burj Khalifa +* source: https://sketchfab.com/3d-models/burj-khalifa-59e6dd74e5f647158de568b5a7f9cab7 +* author: ManySince910 (https://sketchfab.com/noears6) + +Model License: +* license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) +* requirements: Author must be credited. Commercial use is allowed. + +If you use this 3D model in your project be sure to copy paste this credit wherever you share it: +This work is based on "Burj Khalifa" (https://sketchfab.com/3d-models/burj-khalifa-59e6dd74e5f647158de568b5a7f9cab7) by ManySince910 (https://sketchfab.com/noears6) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) \ No newline at end of file diff --git a/demos/public/models/burj_khalifa/scene.bin b/demos/public/models/burj_khalifa/scene.bin new file mode 100644 index 00000000..f6790ff8 Binary files /dev/null and b/demos/public/models/burj_khalifa/scene.bin differ diff --git a/demos/public/models/burj_khalifa/scene.gltf b/demos/public/models/burj_khalifa/scene.gltf new file mode 100644 index 00000000..d2d9276b --- /dev/null +++ b/demos/public/models/burj_khalifa/scene.gltf @@ -0,0 +1,305 @@ +{ + "accessors": [ + { + "bufferView": 2, + "componentType": 5126, + "count": 17715, + "max": [ + 14516.58203125, + 13643.009765625, + 82800.0 + ], + "min": [ + 0.0, + 0.0, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 212580, + "componentType": 5126, + "count": 17715, + "max": [ + 0.9999995827674866, + 0.9999998211860657, + 1.0 + ], + "min": [ + -0.9999995827674866, + -0.9999998211860657, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 17715, + "max": [ + 6.656599044799805, + 13.442420959472656 + ], + "min": [ + -7.337510108947754, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 32418, + "type": "SCALAR" + }, + { + "bufferView": 2, + "byteOffset": 425160, + "componentType": 5126, + "count": 5968, + "max": [ + 14516.58203125, + 13643.009765625, + 72000.0 + ], + "min": [ + 0.0, + 0.0, + 1300.0 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 496776, + "componentType": 5126, + "count": 5968, + "max": [ + 0.0, + 0.0, + 1.0 + ], + "min": [ + 0.0, + 0.0, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 141720, + "componentType": 5126, + "count": 5968, + "max": [ + 0.0, + 55.950660705566406 + ], + "min": [ + -119.06645965576172, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 0, + "byteOffset": 129672, + "componentType": 5125, + "count": 17532, + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "ManySince910 (https://sketchfab.com/noears6)", + "license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)", + "source": "https://sketchfab.com/3d-models/burj-khalifa-59e6dd74e5f647158de568b5a7f9cab7", + "title": "Burj Khalifa" + }, + "generator": "Sketchfab-12.67.0", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 199800, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 189464, + "byteOffset": 199800, + "byteStride": 8, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 568392, + "byteOffset": 389264, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 957656, + "uri": "scene.bin" + } + ], + "images": [ + { + "uri": "textures/material_0_baseColor.png" + } + ], + "materials": [ + { + "doubleSided": true, + "name": "material_0", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.3268125057220459, + 0.3268125057220459, + 0.3268125057220459, + 1.0 + ], + "baseColorTexture": { + "index": 0 + }, + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + }, + { + "doubleSided": true, + "name": "material_1", + "pbrMetallicRoughness": { + "metallicFactor": 0.0, + "roughnessFactor": 0.6 + } + } + ], + "meshes": [ + { + "name": "Object_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0, + "TEXCOORD_0": 2 + }, + "indices": 3, + "material": 0, + "mode": 4 + } + ] + }, + { + "name": "Object_1", + "primitives": [ + { + "attributes": { + "NORMAL": 5, + "POSITION": 4, + "TEXCOORD_0": 6 + }, + "indices": 7, + "material": 1, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.220446049250313e-16, + -1.0, + 0.0, + 0.0, + 1.0, + 2.220446049250313e-16, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "Sketchfab_model" + }, + { + "children": [ + 2 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + -8142.599609375, + -9847.869140625, + 0.0, + 1.0 + ], + "name": "skfb_offset" + }, + { + "children": [ + 3, + 4 + ], + "name": "Object_2" + }, + { + "mesh": 0, + "name": "Object_3" + }, + { + "mesh": 1, + "name": "Object_4" + } + ], + "samplers": [ + { + "magFilter": 9729, + "minFilter": 9987, + "wrapS": 10497, + "wrapT": 10497 + } + ], + "scene": 0, + "scenes": [ + { + "name": "Sketchfab_Scene", + "nodes": [ + 0 + ] + } + ], + "textures": [ + { + "sampler": 0, + "source": 0 + } + ] +} diff --git a/demos/public/models/burj_khalifa/textures/material_0_baseColor.png b/demos/public/models/burj_khalifa/textures/material_0_baseColor.png new file mode 100644 index 00000000..049b60c0 Binary files /dev/null and b/demos/public/models/burj_khalifa/textures/material_0_baseColor.png differ diff --git a/demos/public/models/ufo/license.txt b/demos/public/models/ufo/license.txt new file mode 100644 index 00000000..cc48ca2a --- /dev/null +++ b/demos/public/models/ufo/license.txt @@ -0,0 +1,11 @@ +Model Information: +* title: UFO +* source: https://sketchfab.com/3d-models/ufo-f7ac46de718a444384a73e953d49997c +* author: Graphfun (https://sketchfab.com/the_3d_animate_guy) + +Model License: +* license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) +* requirements: Author must be credited. Commercial use is allowed. + +If you use this 3D model in your project be sure to copy paste this credit wherever you share it: +This work is based on "UFO" (https://sketchfab.com/3d-models/ufo-f7ac46de718a444384a73e953d49997c) by Graphfun (https://sketchfab.com/the_3d_animate_guy) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/) \ No newline at end of file diff --git a/demos/public/models/ufo/scene.bin b/demos/public/models/ufo/scene.bin new file mode 100644 index 00000000..89172adf Binary files /dev/null and b/demos/public/models/ufo/scene.bin differ diff --git a/demos/public/models/ufo/scene.gltf b/demos/public/models/ufo/scene.gltf new file mode 100644 index 00000000..619e992b --- /dev/null +++ b/demos/public/models/ufo/scene.gltf @@ -0,0 +1,1352 @@ +{ + "accessors": [ + { + "bufferView": 3, + "componentType": 5126, + "count": 284, + "max": [ + 5.0, + 0.38378551602363586, + 5.0 + ], + "min": [ + -5.0, + -1.1162145137786865, + -5.0 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 3408, + "componentType": 5126, + "count": 284, + "max": [ + 1.0, + 0.976384162902832, + 1.0 + ], + "min": [ + -1.0, + -0.9872118830680847, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 284, + "max": [ + 1.0, + 1.0 + ], + "min": [ + 0.0, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "componentType": 5125, + "count": 960, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 6816, + "componentType": 5126, + "count": 2343, + "max": [ + 5.003227233886719, + 0.38378551602363586, + 4.988295555114746 + ], + "min": [ + -5.003227233886719, + -1.4580367803573608, + -4.988295555114746 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 34932, + "componentType": 5126, + "count": 2343, + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 2272, + "componentType": 5126, + "count": 2343, + "max": [ + 1.0, + 1.0 + ], + "min": [ + 0.0, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 3840, + "componentType": 5125, + "count": 7992, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 63048, + "componentType": 5126, + "count": 428, + "max": [ + 4.375086784362793, + -0.6564778089523315, + 4.375086307525635 + ], + "min": [ + -4.375086784362793, + -1.875, + -4.375086307525635 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 68184, + "componentType": 5126, + "count": 428, + "max": [ + 0.713324785232544, + -0.6355044841766357, + 0.7133247256278992 + ], + "min": [ + -0.713324785232544, + -1.0, + -0.7133247256278992 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 21016, + "componentType": 5126, + "count": 428, + "max": [ + 1.0, + 0.375 + ], + "min": [ + 0.0, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 35808, + "componentType": 5125, + "count": 624, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 73320, + "componentType": 5126, + "count": 8192, + "max": [ + 2.2039999961853027, + -0.973997950553894, + 2.2039999961853027 + ], + "min": [ + -2.2039999961853027, + -3.6366779804229736, + -2.2039999961853027 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 171624, + "componentType": 5126, + "count": 8192, + "max": [ + 0.8623950481414795, + 0.9533204436302185, + 0.862395167350769 + ], + "min": [ + -0.8623950481414795, + -0.9533205628395081, + -0.862395167350769 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 24440, + "componentType": 5126, + "count": 8192, + "max": [ + 1.0, + 1.0 + ], + "min": [ + 0.0, + 0.0 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 38304, + "componentType": 5125, + "count": 12288, + "type": "SCALAR" + }, + { + "bufferView": 3, + "byteOffset": 269928, + "componentType": 5126, + "count": 600, + "max": [ + 2.5, + 1.875, + 2.5000007152557373 + ], + "min": [ + -2.5, + -0.7175315618515015, + -2.5000007152557373 + ], + "type": "VEC3" + }, + { + "bufferView": 3, + "byteOffset": 277128, + "componentType": 5126, + "count": 600, + "max": [ + 0.8973348140716553, + 0.9872123003005981, + 0.8973348140716553 + ], + "min": [ + -0.8973348140716553, + -0.2379884421825409, + -0.8973348140716553 + ], + "type": "VEC3" + }, + { + "bufferView": 2, + "byteOffset": 89976, + "componentType": 5126, + "count": 600, + "max": [ + 1.0, + 1.0 + ], + "min": [ + 0.0, + 0.3749999403953552 + ], + "type": "VEC2" + }, + { + "bufferView": 1, + "byteOffset": 87456, + "componentType": 5125, + "count": 864, + "type": "SCALAR" + }, + { + "bufferView": 5, + "componentType": 5126, + "count": 6, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "min": [ + 3.1194883121088424e-08, + 0.0, + -0.41318878531455994, + 0.0, + 0.0, + -0.5509184002876282, + 0.0, + 0.0, + -0.41318878531455994, + 0.0, + -3.1194883121088424e-08, + 0.0, + 0.0, + -1.7025338411331177, + 0.0, + 1.0 + ], + "type": "MAT4" + }, + { + "bufferView": 0, + "componentType": 5123, + "count": 284, + "type": "VEC4" + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 284, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 2272, + "componentType": 5123, + "count": 2343, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 4544, + "componentType": 5126, + "count": 2343, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 21016, + "componentType": 5123, + "count": 428, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 42032, + "componentType": 5126, + "count": 428, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 24440, + "componentType": 5123, + "count": 8192, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 48880, + "componentType": 5126, + "count": 8192, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 0, + "byteOffset": 89976, + "componentType": 5123, + "count": 600, + "type": "VEC4" + }, + { + "bufferView": 4, + "byteOffset": 179952, + "componentType": 5126, + "count": 600, + "max": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "min": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "componentType": 5126, + "count": 40, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 8, + "componentType": 5126, + "count": 40, + "max": [ + 0.0, + 0.028559772297739983, + 0.0, + 0.9999660849571228 + ], + "min": [ + 0.0, + -0.6866236329078674, + 0.0, + 0.7270130515098572 + ], + "type": "VEC4" + }, + { + "bufferView": 6, + "byteOffset": 160, + "componentType": 5126, + "count": 32, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "componentType": 5126, + "count": 32, + "max": [ + 1.0000001192092896, + 1.0, + 1.0000001192092896 + ], + "min": [ + 0.9999998807907104, + 1.0, + 0.9999998807907104 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 288, + "componentType": 5126, + "count": 32, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 384, + "componentType": 5126, + "count": 32, + "max": [ + 0.0, + -0.5053561925888062, + 0.0 + ], + "min": [ + 0.0, + -1.7449226379394531, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 416, + "componentType": 5126, + "count": 36, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 768, + "componentType": 5126, + "count": 36, + "max": [ + 1.2100000381469727, + 1.2100000381469727, + 1.2100000381469727 + ], + "min": [ + 0.10000002384185791, + 0.10000002384185791, + 0.10000002384185791 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 560, + "componentType": 5126, + "count": 33, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 1200, + "componentType": 5126, + "count": 33, + "max": [ + 0.0, + -0.5006089210510254, + 0.0 + ], + "min": [ + 0.0, + -1.7401753664016724, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 692, + "componentType": 5126, + "count": 36, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 1596, + "componentType": 5126, + "count": 36, + "max": [ + 1.0889999866485596, + 1.0889999866485596, + 1.0889999866485596 + ], + "min": [ + 0.009000003337860107, + 0.009000003337860107, + 0.009000003337860107 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 836, + "componentType": 5126, + "count": 33, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 2028, + "componentType": 5126, + "count": 33, + "max": [ + 0.0, + -0.5120831727981567, + 0.0 + ], + "min": [ + 0.0, + -1.7516496181488037, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 968, + "componentType": 5126, + "count": 36, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 2424, + "componentType": 5126, + "count": 36, + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + 0.008099973201751709, + 0.008099973201751709, + 0.008099973201751709 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1112, + "componentType": 5126, + "count": 30, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 2856, + "componentType": 5126, + "count": 30, + "max": [ + 0.0, + -0.5120831727981567, + 0.0 + ], + "min": [ + 0.0, + -1.7516496181488037, + 0.0 + ], + "type": "VEC3" + }, + { + "bufferView": 6, + "byteOffset": 1232, + "componentType": 5126, + "count": 32, + "max": [ + 1.6666666269302368 + ], + "min": [ + 0.0416666679084301 + ], + "type": "SCALAR" + }, + { + "bufferView": 7, + "byteOffset": 3216, + "componentType": 5126, + "count": 32, + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + 0.008099973201751709, + 0.008099973201751709, + 0.008099973201751709 + ], + "type": "VEC3" + } + ], + "animations": [ + { + "channels": [ + { + "sampler": 0, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 1, + "target": { + "node": 12, + "path": "scale" + } + }, + { + "sampler": 2, + "target": { + "node": 13, + "path": "translation" + } + }, + { + "sampler": 3, + "target": { + "node": 13, + "path": "scale" + } + }, + { + "sampler": 4, + "target": { + "node": 14, + "path": "translation" + } + }, + { + "sampler": 5, + "target": { + "node": 14, + "path": "scale" + } + }, + { + "sampler": 6, + "target": { + "node": 15, + "path": "translation" + } + }, + { + "sampler": 7, + "target": { + "node": 15, + "path": "scale" + } + }, + { + "sampler": 8, + "target": { + "node": 16, + "path": "translation" + } + }, + { + "sampler": 9, + "target": { + "node": 16, + "path": "scale" + } + } + ], + "name": "ArmatureAction.001", + "samplers": [ + { + "input": 31, + "interpolation": "LINEAR", + "output": 32 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 34 + }, + { + "input": 35, + "interpolation": "LINEAR", + "output": 36 + }, + { + "input": 37, + "interpolation": "LINEAR", + "output": 38 + }, + { + "input": 39, + "interpolation": "LINEAR", + "output": 40 + }, + { + "input": 41, + "interpolation": "LINEAR", + "output": 42 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 44 + }, + { + "input": 45, + "interpolation": "LINEAR", + "output": 46 + }, + { + "input": 47, + "interpolation": "LINEAR", + "output": 48 + }, + { + "input": 49, + "interpolation": "LINEAR", + "output": 50 + } + ] + } + ], + "asset": { + "extras": { + "author": "Graphfun (https://sketchfab.com/the_3d_animate_guy)", + "license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)", + "source": "https://sketchfab.com/3d-models/ufo-f7ac46de718a444384a73e953d49997c", + "title": "UFO" + }, + "generator": "Sketchfab-12.68.0", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 94776, + "byteStride": 8, + "name": "shortBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 90912, + "byteOffset": 94776, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 94776, + "byteOffset": 185688, + "byteStride": 8, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 284328, + "byteOffset": 280464, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 189552, + "byteOffset": 564792, + "byteStride": 16, + "name": "floatBufferViews", + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 384, + "byteOffset": 754344, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 1360, + "byteOffset": 754728, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 3600, + "byteOffset": 756088, + "byteStride": 12, + "name": "floatBufferViews" + }, + { + "buffer": 0, + "byteLength": 640, + "byteOffset": 759688, + "byteStride": 16, + "name": "floatBufferViews" + } + ], + "buffers": [ + { + "byteLength": 760328, + "uri": "scene.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "name": "LightMEtal", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.464008, + 0.464008, + 0.464008, + 1.0 + ], + "roughnessFactor": 0.3 + } + }, + { + "doubleSided": true, + "name": "DarkMetal", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.0704159, + 0.0704159, + 0.0704159, + 1.0 + ], + "roughnessFactor": 0.3 + } + }, + { + "doubleSided": true, + "emissiveFactor": [ + 0.0606849, + 0.271388, + 0.128163 + ], + "name": "Light", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.296315, + 0.8, + 0.42041, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.5 + } + }, + { + "doubleSided": true, + "name": "White", + "pbrMetallicRoughness": { + "metallicFactor": 0.0 + } + }, + { + "doubleSided": true, + "name": "Glass", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.169688, + 0.8, + 0.300744, + 1.0 + ], + "metallicFactor": 0.0, + "roughnessFactor": 0.0 + } + } + ], + "meshes": [ + { + "name": "Object_0", + "primitives": [ + { + "attributes": { + "JOINTS_0": 21, + "NORMAL": 1, + "POSITION": 0, + "TEXCOORD_0": 2, + "WEIGHTS_0": 22 + }, + "indices": 3, + "material": 0, + "mode": 4 + } + ] + }, + { + "name": "Object_1", + "primitives": [ + { + "attributes": { + "JOINTS_0": 23, + "NORMAL": 5, + "POSITION": 4, + "TEXCOORD_0": 6, + "WEIGHTS_0": 24 + }, + "indices": 7, + "material": 1, + "mode": 4 + } + ] + }, + { + "name": "Object_2", + "primitives": [ + { + "attributes": { + "JOINTS_0": 25, + "NORMAL": 9, + "POSITION": 8, + "TEXCOORD_0": 10, + "WEIGHTS_0": 26 + }, + "indices": 11, + "material": 2, + "mode": 4 + } + ] + }, + { + "name": "Object_3", + "primitives": [ + { + "attributes": { + "JOINTS_0": 27, + "NORMAL": 13, + "POSITION": 12, + "TEXCOORD_0": 14, + "WEIGHTS_0": 28 + }, + "indices": 15, + "material": 3, + "mode": 4 + } + ] + }, + { + "name": "Object_4", + "primitives": [ + { + "attributes": { + "JOINTS_0": 29, + "NORMAL": 17, + "POSITION": 16, + "TEXCOORD_0": 18, + "WEIGHTS_0": 30 + }, + "indices": 19, + "material": 4, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.220446049250313e-16, + -1.0, + 0.0, + 0.0, + 1.0, + 2.220446049250313e-16, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "Sketchfab_model" + }, + { + "children": [ + 2 + ], + "name": "root" + }, + { + "children": [ + 3 + ], + "matrix": [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.220446049250313e-16, + 1.0, + 0.0, + 0.0, + -1.0, + 2.220446049250313e-16, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "GLTF_SceneRootNode" + }, + { + "children": [ + 4 + ], + "matrix": [ + 1.2101006507873535, + 0.0, + 0.0, + 0.0, + 0.0, + 1.2101006507873535, + 0.0, + 0.0, + 0.0, + 0.0, + 1.2101006507873535, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "name": "Armature_6" + }, + { + "children": [ + 5, + 7, + 8, + 9, + 10, + 11, + 6 + ], + "name": "GLTF_created_0" + }, + { + "children": [ + 12, + 13, + 14, + 15, + 16 + ], + "name": "GLTF_created_0_rootJoint" + }, + { + "matrix": [ + 0.41318878531455994, + 0.0, + 0.0, + 0.0, + 0.0, + 0.5509184002876282, + 0.0, + 0.0, + 0.0, + 0.0, + 0.41318878531455994, + 0.0, + 0.0, + -0.20175430178642273, + 0.0, + 1.0 + ], + "name": "Body_5" + }, + { + "mesh": 0, + "name": "Object_7", + "skin": 0 + }, + { + "mesh": 1, + "name": "Object_8", + "skin": 0 + }, + { + "mesh": 2, + "name": "Object_9", + "skin": 0 + }, + { + "mesh": 3, + "name": "Object_10", + "skin": 0 + }, + { + "mesh": 4, + "name": "Object_11", + "skin": 0 + }, + { + "name": "Body_0", + "rotation": [ + 0.0, + 0.046540696173906326, + 0.0, + 0.9989163875579834 + ] + }, + { + "name": "Trail1_1", + "rotation": [ + 0.7071068286895752, + 0.0, + -0.7071068286895752, + 0.0 + ], + "translation": [ + 0.0, + -1.7449225187301636, + 0.0 + ] + }, + { + "name": "Trail2_2", + "rotation": [ + 0.7071068286895752, + 0.0, + -0.7071068286895752, + 0.0 + ], + "translation": [ + 0.0, + -1.120392084121704, + 0.0 + ] + }, + { + "name": "Trail3_3", + "rotation": [ + 0.7071068286895752, + 0.0, + -0.7071068286895752, + 0.0 + ], + "translation": [ + 0.0, + -0.5120831727981567, + 0.0 + ] + }, + { + "name": "Trail4_4", + "rotation": [ + 0.7071068286895752, + 0.0, + -0.7071068286895752, + 0.0 + ], + "translation": [ + 0.0, + -0.5120831727981567, + 0.0 + ] + } + ], + "scene": 0, + "scenes": [ + { + "name": "Sketchfab_Scene", + "nodes": [ + 0 + ] + } + ], + "skins": [ + { + "inverseBindMatrices": 20, + "joints": [ + 5, + 12, + 13, + 14, + 15, + 16 + ], + "skeleton": 5 + } + ] +} diff --git a/demos/public/routes/markers.geojson b/demos/public/routes/markers.geojson new file mode 100644 index 00000000..2d966824 --- /dev/null +++ b/demos/public/routes/markers.geojson @@ -0,0 +1,33 @@ + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + -7.453472539769251, + 39.415519175998355 + ], + + [ + -7.464899329058538, + 39.41042140236023 + ], + + [ + -7.445160082758153, + 39.41918918515162 + ], + + [ + -7.457803520606888, + 39.41755616216083 + ], + + [ + -7.453472539769251, + 39.415519175998355 + ] + ], + "type": "MultiPoint" + } + } \ No newline at end of file diff --git a/demos/public/routes/mt.png b/demos/public/routes/mt.png new file mode 100644 index 00000000..dbfbe378 Binary files /dev/null and b/demos/public/routes/mt.png differ diff --git a/demos/public/routes/run.geojson b/demos/public/routes/run.geojson new file mode 100644 index 00000000..e6198005 --- /dev/null +++ b/demos/public/routes/run.geojson @@ -0,0 +1,780 @@ + +{ + "type": "Feature", + "properties": { + "bearing": [ + -30, + 150, + -30 + ], + "zoom": [ + 17, + 15, + 15, + 16, + 17 + ], + "pitch": [ + 60, + 50, + 35, + 30, + 35, + 50, + 60 + ] + }, + "geometry": { + "coordinates": [ + [ + -7.453472539769251, + 39.415519175998355 + ], + [ + -7.45325336192937, + 39.4156388735492 + ], + [ + -7.453531113157567, + 39.41581404032513 + ], + [ + -7.453788080279594, + 39.41604175647706 + ], + [ + -7.453922232234078, + 39.4161687516999 + ], + [ + -7.454007258120328, + 39.416221301379295 + ], + [ + -7.454099841862472, + 39.416221301379295 + ], + [ + -7.454256667386403, + 39.41615269484561 + ], + [ + -7.45438326148323, + 39.41629428698019 + ], + [ + -7.45458921307457, + 39.41647529094024 + ], + [ + -7.454659123247268, + 39.416576010681865 + ], + [ + -7.454685575745117, + 39.41663147946278 + ], + [ + -7.454672349496633, + 39.41666505265138 + ], + [ + -7.454634560213577, + 39.41668402879404 + ], + [ + -7.454536308077934, + 39.41669862582319 + ], + [ + -7.454436166479013, + 39.41673073927731 + ], + [ + -7.4543001250612235, + 39.41675701390977 + ], + [ + -7.454139251708426, + 39.41679288839924 + ], + [ + -7.4541169768876046, + 39.416831129183436 + ], + [ + -7.454022927641404, + 39.41684068937607 + ], + [ + -7.453847204051328, + 39.41685024956789 + ], + [ + -7.453745729865574, + 39.41685024956789 + ], + [ + -7.453718505084282, + 39.41686936994665 + ], + [ + -7.45371108014362, + 39.416907610689265 + ], + [ + -7.453659222148588, + 39.416917485473704 + ], + [ + -7.453665223396598, + 39.416982392814276 + ], + [ + -7.453737238370309, + 39.41699398340427 + ], + [ + -7.4536952296354, + 39.417061208789335 + ], + [ + -7.453656221524511, + 39.417102934858036 + ], + [ + -7.453659222148588, + 39.41715393335227 + ], + [ + -7.454452481972908, + 39.41719879152794 + ], + [ + -7.4556627068376145, + 39.417574698550965 + ], + [ + -7.455824901922853, + 39.41765502030117 + ], + [ + -7.456024526643432, + 39.41797951923206 + ], + [ + -7.455915536646046, + 39.418009529115835 + ], + [ + -7.456252667211032, + 39.41869583306371 + ], + [ + -7.456503237226002, + 39.41893867745799 + ], + [ + -7.456676358327115, + 39.41920967672442 + ], + [ + -7.456676358327115, + 39.419427883161006 + ], + [ + -7.456717360693318, + 39.41967424444505 + ], + [ + -7.456872258520065, + 39.420138809069385 + ], + [ + -7.456815186161634, + 39.42029664150152 + ], + [ + -7.456959227598048, + 39.42076272793693 + ], + [ + -7.4570407604869615, + 39.42104825682648 + ], + [ + -7.457206544027741, + 39.42120361764262 + ], + [ + -7.4573560209915115, + 39.42125610432626 + ], + [ + -7.457470486396545, + 39.42138174472842 + ], + [ + -7.458314966363702, + 39.421880895611764 + ], + [ + -7.458410929996376, + 39.422179890220576 + ], + [ + -7.458426923934923, + 39.422281202152874 + ], + [ + -7.45831176757585, + 39.42238251393735 + ], + [ + -7.458388538482154, + 39.42255548493705 + ], + [ + -7.458494098478383, + 39.422599963124384 + ], + [ + -7.45864764029028, + 39.422585137065084 + ], + [ + -7.458695622106632, + 39.422639499267376 + ], + [ + -7.4586316463517335, + 39.422827295640246 + ], + [ + -7.458850762438146, + 39.422921396416655 + ], + [ + -7.45920297852274, + 39.42296976613008 + ], + [ + -7.459304729836731, + 39.42301511270648 + ], + [ + -7.4593634325176765, + 39.4236136847492 + ], + [ + -7.459766524259692, + 39.4236136847492 + ], + [ + -7.45997549214124, + 39.42376808151175 + ], + [ + -7.460162959559625, + 39.4238364653921 + ], + [ + -7.460402501261342, + 39.42404563919828 + ], + [ + -7.460402501261342, + 39.42432721833063 + ], + [ + -7.460631628106171, + 39.42456857096724 + ], + [ + -7.460985733230331, + 39.42499495858232 + ], + [ + -7.461162785791771, + 39.42510758883736 + ], + [ + -7.461292971499546, + 39.42547765839436 + ], + [ + -7.461595002340374, + 39.425537995635835 + ], + [ + -7.461742045047515, + 39.42531890662306 + ], + [ + -7.461907232673752, + 39.42522650679359 + ], + [ + -7.463158244162145, + 39.4252026823261 + ], + [ + -7.464713077858249, + 39.42464219568453 + ], + [ + -7.465438666916157, + 39.42433335417971 + ], + [ + -7.466149448034429, + 39.423738544463674 + ], + [ + -7.46665291799269, + 39.42317804604639 + ], + [ + -7.466667725933149, + 39.42254890980095 + ], + [ + -7.46663811005314, + 39.42114191182756 + ], + [ + -7.466771381512729, + 39.420501317975436 + ], + [ + -7.46715638795186, + 39.420467000281036 + ], + [ + -7.4675117785109535, + 39.42051275720269 + ], + [ + -7.4681781358088415, + 39.420295411557646 + ], + [ + -7.468651989887974, + 39.42044412180894 + ], + [ + -7.468933340747554, + 39.42011238312082 + ], + [ + -7.469392386886284, + 39.419792082199365 + ], + [ + -7.469881048904995, + 39.41958617368718 + ], + [ + -7.470280863283676, + 39.41911715758329 + ], + [ + -7.469747777445406, + 39.41876253355514 + ], + [ + -7.468000440529693, + 39.41816767629666 + ], + [ + -7.468459486668394, + 39.417904564735636 + ], + [ + -7.469644121865855, + 39.41797320262998 + ], + [ + -7.470917604702407, + 39.41815623668384 + ], + [ + -7.471909736679407, + 39.41848798468172 + ], + [ + -7.473434954495559, + 39.41900276293251 + ], + [ + -7.4738643847542505, + 39.41901420240572 + ], + [ + -7.474486318233431, + 39.41901420240572 + ], + [ + -7.475063827891262, + 39.41922011260817 + ], + [ + -7.475345178750814, + 39.41916291539039 + ], + [ + -7.475359986690421, + 39.41896844450099 + ], + [ + -7.4748417087917005, + 39.41876253355514 + ], + [ + -7.474708437332083, + 39.41865957785441 + ], + [ + -7.474856516732103, + 39.41848798468172 + ], + [ + -7.475345178750814, + 39.41867101738458 + ], + [ + -7.476026324721147, + 39.41899133597087 + ], + [ + -7.476781529658979, + 39.41905997279508 + ], + [ + -7.4773738472581215, + 39.418831183118755 + ], + [ + -7.4776551981168495, + 39.41825920564219 + ], + [ + -7.47761077429729, + 39.41776730125912 + ], + [ + -7.477107304338148, + 39.41752706762398 + ], + [ + -7.476529794680289, + 39.41733259217017 + ], + [ + -7.475493238882848, + 39.41705803766487 + ], + [ + -7.474900921283705, + 39.41687500072743 + ], + [ + -7.474517799683866, + 39.416176041313406 + ], + [ + -7.473882333590893, + 39.41556609271535 + ], + [ + -7.47324686749792, + 39.414732983569564 + ], + [ + -7.472746197242628, + 39.41430154813483 + ], + [ + -7.472187757342937, + 39.41419740814641 + ], + [ + -7.471128647187953, + 39.414108145176584 + ], + [ + -7.470396898352789, + 39.41384035557883 + ], + [ + -7.469838458453069, + 39.41331965064083 + ], + [ + -7.469434070938888, + 39.412769186905365 + ], + [ + -7.468933400684676, + 39.41250139216473 + ], + [ + -7.4679705732707475, + 39.412322861767024 + ], + [ + -7.467200311340065, + 39.41204018770151 + ], + [ + -7.46654558869821, + 39.411489713864455 + ], + [ + -7.465794583315301, + 39.410790457048535 + ], + [ + -7.465582761284281, + 39.41010607124639 + ], + [ + -7.465409452349917, + 39.4095704602812 + ], + [ + -7.4647932428058255, + 39.40901996694589 + ], + [ + -7.463734132650842, + 39.408409955741405 + ], + [ + -7.4628098183335965, + 39.40848434763711 + ], + [ + -7.462155095691685, + 39.408573617807775 + ], + [ + -7.461250037923293, + 39.408216536440364 + ], + [ + -7.460826393861282, + 39.40830580695385 + ], + [ + -7.46053754563701, + 39.408335563766286 + ], + [ + -7.460595315281495, + 39.40875215780622 + ], + [ + -7.460526750518596, + 39.408810938181375 + ], + [ + -7.460538494150569, + 39.408901673642674 + ], + [ + -7.460456288729574, + 39.40896065162889 + ], + [ + -7.460303621518165, + 39.40899694574969 + ], + [ + -7.460356467860635, + 39.40911036475495 + ], + [ + -7.459998287096738, + 39.40900601927663 + ], + [ + -7.4595285418332935, + 39.409155732305294 + ], + [ + -7.45898833478077, + 39.409219246826865 + ], + [ + -7.45882392393878, + 39.40924193057006 + ], + [ + -7.458753462148337, + 39.409319055243145 + ], + [ + -7.459193848333285, + 39.40976819134863 + ], + [ + -7.459193848333285, + 39.40984531543913 + ], + [ + -7.458894385727831, + 39.40990882933207 + ], + [ + -7.458712359437868, + 39.40997234316711 + ], + [ + -7.458683000359343, + 39.410044930336596 + ], + [ + -7.458747590332393, + 39.41012205412102 + ], + [ + -7.458418768648386, + 39.410230934613196 + ], + [ + -7.458254357806396, + 39.410557575068566 + ], + [ + -7.458013613358048, + 39.410807091052334 + ], + [ + -7.457813971621476, + 39.41106114277309 + ], + [ + -7.457532124462631, + 39.41128797388461 + ], + [ + -7.457608458068989, + 39.411410462377546 + ], + [ + -7.45750863720005, + 39.41160099960618 + ], + [ + -7.457440569868993, + 39.41178964110193 + ], + [ + -7.457596686546822, + 39.4118785161441 + ], + [ + -7.457161203182466, + 39.41249429011131 + ], + [ + -7.456914703165921, + 39.41275456406984 + ], + [ + -7.456873619829366, + 39.41298944461357 + ], + [ + -7.456840753160321, + 39.413167191526526 + ], + [ + -7.4562409364515645, + 39.41309736243639 + ], + [ + -7.4562409364515645, + 39.41327510907348 + ], + [ + -7.456035519770694, + 39.41351633592819 + ], + [ + -7.455920486429449, + 39.41358616459871 + ], + [ + -7.455419270830589, + 39.41363695090493 + ], + [ + -7.455131687477518, + 39.41360521063166 + ], + [ + -7.4553781874950005, + 39.41390991665597 + ], + [ + -7.4553864041615725, + 39.414017833053975 + ], + [ + -7.454983787467171, + 39.41377660793444 + ], + [ + -7.454794804120354, + 39.41374486772477 + ], + [ + -7.4544004040925245, + 39.413922612711815 + ], + [ + -7.454121037405997, + 39.41407496519642 + ], + [ + -7.453882754056025, + 39.41429079731378 + ], + [ + -7.4538005873838244, + 39.41436062520859 + ], + [ + -7.454318237420381, + 39.414785939057936 + ], + [ + -7.454597604106908, + 39.415014464830534 + ], + [ + -7.454326454087862, + 39.415185858667996 + ], + [ + -7.453808804051306, + 39.41536994787708 + ], + [ + -7.453545870698861, + 39.415490557783954 + ], + [ + -7.453472539769251, + 39.415519175998355 + ], + [ + -7.453472539769251, + 39.415519175998355 + ] + ], + "type": "LineString" + } +} \ No newline at end of file diff --git a/demos/src/11-animated-routes.ts b/demos/src/11-animated-routes.ts new file mode 100644 index 00000000..ec24cb5f --- /dev/null +++ b/demos/src/11-animated-routes.ts @@ -0,0 +1,292 @@ +import "../../build/maptiler-sdk.css"; + +import { addPerformanceStats, setupMapTilerApiKey } from "./demo-utils"; +import { AnimatedRouteLayer, AnimationEvent, Keyframe, Map, MapStyle, config } from "../../src"; + +addPerformanceStats(); +setupMapTilerApiKey({ config }); + +function getElementsByIds(ids: string[]) { + return ids.map((id) => document.getElementById(id)); +} + +async function createMap() { + const element = document.createElement("div"); + element.className = "map"; + document.body.appendChild(element); + + const map = new Map({ + container: element, + style: MapStyle.OUTDOOR.DARK, + hash: false, + geolocateControl: false, + navigationControl: false, + terrain: true, + bearing: -30, + maxPitch: 65, + pitch: 60, + zoom: 17, + center: [-7.453472539769251, 39.415519175998355], + }); + + await map.onReadyAsync(); + + return map; +} + +async function loadGeoJSON(path: string) { + const response = await fetch(path); + const data = (await response.json()) as GeoJSON.FeatureCollection; + return data; +} + +async function main() { + const pathGeojson = await loadGeoJSON("/routes/run.geojson"); + const markersGeojson = await loadGeoJSON("/routes/markers.geojson"); + + const map = await createMap(); + + const ROUTE_SOURCE_ID = "routes"; + const MARKER_SOURCE_ID = "markers"; + + map.addSource(ROUTE_SOURCE_ID, { + type: "geojson", + data: pathGeojson, + lineMetrics: true, // this is needed for the animated route + }); + + map.addSource(MARKER_SOURCE_ID, { + type: "geojson", + data: markersGeojson, + }); + + map.addLayer({ + id: ROUTE_SOURCE_ID, + type: "line", + source: ROUTE_SOURCE_ID, + layout: { + "line-join": "round", + "line-cap": "round", + visibility: "visible", + }, + paint: { + "line-color": `rgba(0, 255, 0, 0.75)`, + "line-width": 8, + "line-opacity": 0.75, + }, + }); + + const img = await map.loadImage("/routes/mt.png"); + + map.addImage("maptiler", img.data); + + map.addLayer({ + id: MARKER_SOURCE_ID, + type: "symbol", + source: MARKER_SOURCE_ID, + layout: { + "icon-image": "maptiler", + "icon-size": 0.2, + }, + paint: {}, + }); + + const animatedRouteLayer = new AnimatedRouteLayer({ + source: { + id: ROUTE_SOURCE_ID, + layerID: ROUTE_SOURCE_ID, + }, + duration: 25000, + iterations: 3, + pathStrokeAnimation: { + activeColor: [255, 123, 0, 0.75], + inactiveColor: [0, 255, 0, 0.75], + }, + cameraAnimation: { + follow: true, + pathSmoothing: { + resolution: 20, + epsilon: 10, + }, + }, + }); + + map.addLayer(animatedRouteLayer); + + const animatedMarkersLayer = new AnimatedRouteLayer({ + keyframes: getKeyframes() as Keyframe[], + duration: 15000, + delay: 1000, + iterations: 5, + cameraAnimation: { + follow: true, + pathSmoothing: false, + }, + }); + + const keyframeNameElement = document.getElementById("keyframeName"); + + animatedRouteLayer.addEventListener("animationend", () => { + if (!keyframeNameElement) return; + map.removeLayer(animatedRouteLayer.id).addLayer(animatedMarkersLayer); + + keyframeNameElement.style.display = "block"; + + animatedMarkersLayer.play(); + }); + + animatedMarkersLayer.addEventListener("animationend", () => { + if (!keyframeNameElement) return; + map.removeLayer(animatedMarkersLayer.id).addLayer(animatedRouteLayer); + + keyframeNameElement.style.display = "none"; + + animatedRouteLayer.play(); + }); + + animatedMarkersLayer.addEventListener("keyframe", (e: AnimationEvent) => { + const keyframeName = e.keyframe?.userData?.name; + if (keyframeNameElement) keyframeNameElement.innerText = keyframeName; + }); + + animatedRouteLayer.addEventListener("playbackratechange", (e) => { + const playbackRateElement = document.getElementById("playbackRate"); + if (!playbackRateElement) return; + playbackRateElement.innerText = e.playbackRate.toFixed(1) + "x"; + }); + + const [playButton, pauseButton, fasterButton, slowerButton] = getElementsByIds(["play", "pause", "faster", "slower"]); + + playButton?.addEventListener("click", () => { + animatedRouteLayer.play(); + }); + + pauseButton?.addEventListener("click", () => { + animatedRouteLayer.pause(); + }); + + fasterButton?.addEventListener("click", () => { + if (!animatedRouteLayer.animationInstance) return; + const currentSpeed = animatedRouteLayer.animationInstance.getPlaybackRate(); + animatedRouteLayer.animationInstance.setPlaybackRate(currentSpeed + 0.2); + }); + + slowerButton?.addEventListener("click", () => { + if (!animatedRouteLayer.animationInstance) return; + const currentSpeed = animatedRouteLayer.animationInstance.getPlaybackRate(); + animatedRouteLayer.animationInstance.setPlaybackRate(currentSpeed - 0.2); + }); +} + +void main(); + +function getKeyframes() { + return [ + { + delta: 0, + easing: "ElasticOut", + userData: { + name: "Start", + }, + props: { + lng: -7.453472539769251, + lat: 39.415519175998355, + zoom: 16, + pitch: 40, + bearing: -30, + }, + }, + { + delta: 0.1, + easing: "ElasticOut", + userData: { + name: "Nossa Senhora da Penha 1", + }, + props: { + lng: -7.464899329058538, + lat: 39.41042140236023, + zoom: 16, + pitch: 60, + bearing: -70, + }, + }, + { + delta: 0.3, + easing: "ElasticOut", + userData: { + name: "Nossa Senhora da Penha 2", + }, + props: { + lng: -7.464899329058538, + lat: 39.41042140236023, + zoom: 16, + pitch: 60, + bearing: -70, + }, + }, + { + delta: 0.5, + easing: "ElasticOut", + userData: { + name: "Farmland 1", + }, + props: { + lng: -7.445160082758153, + lat: 39.41918918515162, + zoom: 15, + pitch: 90, + bearing: -150, + }, + }, + { + delta: 0.7, + easing: "BounceIn", + userData: { + name: "Farmland 2", + }, + props: { + lng: -7.445160082758153, + lat: 39.41918918515162, + zoom: 15, + }, + }, + { + delta: 0.8, + easing: "BounceOut", + userData: { + name: "Castelo de Vide: Castelo 1", + }, + props: { + lng: -7.457803520606888, + lat: 39.41755616216083, + zoom: 16, + }, + }, + { + delta: 0.9, + easing: "ElasticOut", + userData: { + name: "Castelo de Vide: Castelo 1", + }, + props: { + lng: -7.457803520606888, + lat: 39.41755616216083, + zoom: 16, + }, + }, + { + delta: 1, + easing: "ElasticOut", + userData: { + name: "Finish", + }, + props: { + lng: -7.453472539769251, + lat: 39.415519175998355, + zoom: 17, + pitch: 60, + bearing: -330, + }, + }, + ]; +} diff --git a/demos/src/12-maptiler-animation.ts b/demos/src/12-maptiler-animation.ts new file mode 100644 index 00000000..b77fe928 --- /dev/null +++ b/demos/src/12-maptiler-animation.ts @@ -0,0 +1,163 @@ +import "../../build/maptiler-sdk.css"; +import { Layer3D } from "@maptiler/3d"; +import { CustomLayerInterface, Map, MapStyle, config, Keyframe, MaptilerAnimation } from "../../src"; +import { addPerformanceStats, setupMapTilerApiKey } from "./demo-utils"; + +addPerformanceStats(); +setupMapTilerApiKey({ config }); + +function getElementsByIds(ids: string[]) { + return ids.map((id) => document.getElementById(id)); +} + +async function createMap() { + const element = document.createElement("div"); + element.className = "map"; + document.body.appendChild(element); + + const map = new Map({ + container: element, + style: MapStyle.STREETS.DARK, + hash: false, + geolocateControl: false, + navigationControl: false, + terrain: true, + bearing: -30, + maxPitch: 60, + minPitch: 60, + pitch: 60, + zoom: 16.3, + maxZoom: 16.3, + minZoom: 16.3, + center: [55.2743164, 25.1973882], + }); + + await map.onReadyAsync(); + + return map; +} + +async function main() { + const map = await createMap(); + + const animation = new MaptilerAnimation({ + keyframes: getKeyframes() as Keyframe[], + duration: 2500, + iterations: Infinity, + }); + + const layer3D = new Layer3D("3d-scene"); + map.addLayer(layer3D as CustomLayerInterface); + + await layer3D.addMeshFromURL( + // ID to give to this mesh, unique within this Layer3D instance + "khalifa", + + // The URL of the mesh + "/models/burj_khalifa/scene.gltf", + + // A set of options, these can be modified later + { + lngLat: [55.2743164, 25.1973882], + heading: -2, + scale: 0.013, + }, + ); + + await layer3D.addMeshFromURL( + // ID to give to this mesh, unique within this Layer3D instance + "ufo", + + // The URL of the mesh + "/models/ufo/scene.gltf", + + // A set of options, these can be modified later + { + lngLat: [55.273, 25.197], + altitude: 500, + scale: 10, + }, + ); + + animation.addEventListener("timeupdate", (e) => { + map.setBearing(map.getBearing() + 0.1); + const ufo = layer3D.getItem3D("ufo"); + ufo?.modify({ + lngLat: [e.props.lng, e.props.lat], + altitude: e.props.altitude, + }); + }); + + animation.addEventListener("playbackratechange", (e) => { + const playbackRateElement = document.getElementById("playbackRate"); + if (!playbackRateElement) return; + playbackRateElement.innerText = e.playbackRate.toFixed(1) + "x"; + }); + + const [playButton, pauseButton, fasterButton, slowerButton, frameAdvanceButton] = getElementsByIds(["play", "pause", "faster", "slower", "frame-advance"]); + + playButton?.addEventListener("click", () => { + animation.play(); + }); + + pauseButton?.addEventListener("click", () => { + animation.pause(); + }); + + fasterButton?.addEventListener("click", () => { + const currentSpeed = animation.getPlaybackRate(); + animation.setPlaybackRate(currentSpeed + 0.2); + }); + + slowerButton?.addEventListener("click", () => { + const currentSpeed = animation.getPlaybackRate(); + animation.setPlaybackRate(currentSpeed - 0.2); + }); + + frameAdvanceButton?.addEventListener("click", () => { + animation.setCurrentDelta(animation.getCurrentDelta() + 0.01); + }); +} + +void main(); + +function getKeyframes() { + return [ + { + delta: 0, + easing: "ElasticInOut", + props: { + lng: 55.273, + lat: 25.197, + altitude: 300, + }, + }, + { + delta: 0.33333, + easing: "ElasticInOut", + props: { + lng: 55.27439873444448, + lat: 25.198719685352263, + altitude: 250, + }, + }, + { + delta: 0.666666, + easing: "ElasticInOut", + props: { + lng: 55.2753633312754, + lat: 25.195489657613702, + altitude: 350, + }, + }, + { + delta: 1.0, + easing: "ElasticInOut", + props: { + lng: 55.273, + lat: 25.197, + altitude: 300, + }, + }, + ]; +} diff --git a/e2e/global.d.ts b/e2e/global.d.ts index 6b6ecd30..1db228ab 100644 --- a/e2e/global.d.ts +++ b/e2e/global.d.ts @@ -4,9 +4,14 @@ declare global { interface Window { __map: Map; __pageObjects: Record; + __pageLoadTimeout: number; notifyScreenshotStateReady: (data: TTestTransferData) => Promise; notifyTest: (data: TTestTransferData) => Promise; + __MT_SDK_VERSION__: string; + __MT_NODE_ENV__: string | undefined; } type TTestTransferData = string | number | boolean | string[] | number[] | boolean[] | null | Record | [number, number]; } + +export {}; diff --git a/e2e/public/animated-route.geojson b/e2e/public/animated-route.geojson new file mode 100644 index 00000000..0f64886a --- /dev/null +++ b/e2e/public/animated-route.geojson @@ -0,0 +1,82 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "pitch": [ + 30, + 40, + 50, + 60, + 50, + 40, + 30 + ], + "bearing": [ + 0, + 180, + 360 + ], + "zoom": [ + 13.5, + 14, + 14.5, + 15, + 14.5, + 14, + 13.5 + ] + }, + "geometry": { + "coordinates": [ + [ + -5.513465218122661, + 55.44452556522981 + ], + [ + -5.498757996681263, + 55.45488849896259 + ], + [ + -5.4776627901701715, + 55.459774141237716 + ], + [ + -5.450773829757111, + 55.45446729456921 + ], + [ + -5.434581030391769, + 55.44241894502119 + ], + [ + -5.434433020938883, + 55.42337006037573 + ], + [ + -5.447655667245527, + 55.40895140980018 + ], + [ + -5.474692405196635, + 55.40169789075969 + ], + [ + -5.504997423959253, + 55.410637475280794 + ], + [ + -5.520595992154796, + 55.42809094495976 + ], + [ + -5.513910891499705, + 55.44393572290733 + ] + ], + "type": "LineString" + } + } + ] +} \ No newline at end of file diff --git a/e2e/public/animatedRouteLayer.html b/e2e/public/animatedRouteLayer.html new file mode 100644 index 00000000..69e344dc --- /dev/null +++ b/e2e/public/animatedRouteLayer.html @@ -0,0 +1,24 @@ + + + + + + MapTiler E2E Animated Route Layer + + + +
+ + + \ No newline at end of file diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-0-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-0-chromium-linux.png new file mode 100644 index 00000000..cb3b5c73 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-0-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-1-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-1-chromium-linux.png new file mode 100644 index 00000000..009a132b Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-1-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-10-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-10-chromium-linux.png new file mode 100644 index 00000000..36d782d2 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-10-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-11-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-11-chromium-linux.png new file mode 100644 index 00000000..50a08e11 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-11-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-12-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-12-chromium-linux.png new file mode 100644 index 00000000..ce55d433 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-12-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-13-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-13-chromium-linux.png new file mode 100644 index 00000000..35db9210 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-13-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-14-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-14-chromium-linux.png new file mode 100644 index 00000000..7666ae30 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-14-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-15-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-15-chromium-linux.png new file mode 100644 index 00000000..bcbd1aaa Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-15-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-16-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-16-chromium-linux.png new file mode 100644 index 00000000..75eab2ce Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-16-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-17-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-17-chromium-linux.png new file mode 100644 index 00000000..9c293b9c Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-17-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-18-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-18-chromium-linux.png new file mode 100644 index 00000000..d451fd45 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-18-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-19-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-19-chromium-linux.png new file mode 100644 index 00000000..96cbde35 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-19-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-2-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-2-chromium-linux.png new file mode 100644 index 00000000..62658e4b Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-2-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-3-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-3-chromium-linux.png new file mode 100644 index 00000000..2fd47c58 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-3-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-4-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-4-chromium-linux.png new file mode 100644 index 00000000..adbd2c4f Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-4-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-5-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-5-chromium-linux.png new file mode 100644 index 00000000..2397adb6 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-5-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-6-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-6-chromium-linux.png new file mode 100644 index 00000000..8835fa04 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-6-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-7-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-7-chromium-linux.png new file mode 100644 index 00000000..8a0f8f0a Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-7-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-8-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-8-chromium-linux.png new file mode 100644 index 00000000..660263fe Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-8-chromium-linux.png differ diff --git a/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-9-chromium-linux.png b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-9-chromium-linux.png new file mode 100644 index 00000000..aa40c260 Binary files /dev/null and b/e2e/snapshots/tests/AnimatedRouteLayer.test.ts-snapshots/animated-route-9-chromium-linux.png differ diff --git a/e2e/src/animatedRouteLayer.ts b/e2e/src/animatedRouteLayer.ts new file mode 100644 index 00000000..7db0a6a6 --- /dev/null +++ b/e2e/src/animatedRouteLayer.ts @@ -0,0 +1,72 @@ +import { AnimatedRouteLayer } from "../../src/custom-layers/AnimatedRouteLayer"; +import { Map as MapTiler, MapStyle } from "../../src/index"; +import fetchGeoJSON from "../tests/helpers/fetchGeojson"; + +async function main() { + console.log("main....running"); + const map = new MapTiler({ + container: "map", + apiKey: "DOESNT_MATTER", + style: MapStyle.SATELLITE, + projection: "globe", + pitch: 30, + bearing: 0, + zoom: 13.5, + center: [-5.513465218122661, 55.44452556522981], + }); + + window.__map = map; + + const geojson = await fetchGeoJSON("/animated-route.geojson"); + + await map.onReadyAsync(); + + map.addSource("route-source", { + type: "geojson", + data: geojson, + lineMetrics: true, + }); + + map.addLayer({ + id: "route-layer", + type: "line", + source: "route-source", + layout: { + "line-cap": "round", + "line-join": "round", + }, + paint: { + "line-width": 5, + "line-color": "#FF0000", + "line-opacity": 0.8, + }, + }); + + const animatedRouteLayer = new AnimatedRouteLayer({ + manualUpdate: true, + source: { + id: "route-source", + layerID: "route-layer", + }, + duration: 10000, + iterations: 1, + pathStrokeAnimation: { + activeColor: [0, 255, 0, 1], + inactiveColor: [100, 100, 100, 0.5], + }, + cameraAnimation: { + pathSmoothing: { + resolution: 20, + epsilon: 2, + }, + }, + }); + + map.addLayer(animatedRouteLayer); + + window.__pageObjects = { + animatedRouteLayer, + }; +} + +main(); diff --git a/e2e/tests/AnimatedRouteLayer.test.ts b/e2e/tests/AnimatedRouteLayer.test.ts new file mode 100644 index 00000000..62aefa1f --- /dev/null +++ b/e2e/tests/AnimatedRouteLayer.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import getMapInstanceForFixture from "./helpers/getMapInstanceForFixture"; +import { AnimatedRouteLayer } from "index"; +import { Map as SDKMap } from "../../src"; +import expected from "./expected-results/animatedRouteLayer-1.json" assert { type: "json" }; + +test.setTimeout(60000); + +test("Follows the correct path taking screenshots at each interval", async ({ page }) => { + await getMapInstanceForFixture({ + fixture: "animatedRouteLayer", + page, + timeout: 20000, + }); + + expect(await page.title()).toBe("MapTiler E2E Animated Route Layer"); + + await page.exposeFunction("notifyScreenshotStateReady", async (data: Record) => { + await expect(page).toHaveScreenshot(`animated-route-${data.frame}.png`, { timeout: 10000 }); + expect(data).toEqual(expected[data.frame as number]); + }); + + await page.evaluate(async () => { + const NUM_SCREENSHOTS = 20; + const NUM_FRAMES_BETWEEN_SCREENSHOTS = 20; + + const { animatedRouteLayer } = window.__pageObjects as { animatedRouteLayer: AnimatedRouteLayer }; + const map = window.__map as SDKMap; + + for (let i = 0; i < NUM_SCREENSHOTS; i++) { + for (let j = 0; j < NUM_FRAMES_BETWEEN_SCREENSHOTS; j++) { + animatedRouteLayer.updateManual(); + } + await window.notifyScreenshotStateReady({ + frame: i, + center: map.getCenter().toArray(), + zoom: map.getZoom(), + pitch: map.getPitch(), + bearing: map.getBearing(), + }); + } + }); + + await page.clock.runFor(10000); +}); diff --git a/e2e/tests/consts.ts b/e2e/tests/consts.ts new file mode 100644 index 00000000..e69de29b diff --git a/e2e/tests/expected-results/animatedRouteLayer-1.json b/e2e/tests/expected-results/animatedRouteLayer-1.json new file mode 100644 index 00000000..c08a0c21 --- /dev/null +++ b/e2e/tests/expected-results/animatedRouteLayer-1.json @@ -0,0 +1,202 @@ +[ + { + "frame": 0, + "center": [ + -5.466042011463379, + 55.4577906319048 + ], + "zoom": 13.598461538461539, + "pitch": 31.96923076923077, + "bearing": 11.519999999999984 + }, + { + "frame": 1, + "center": [ + -5.460399670970225, + 55.4563116595035 + ], + "zoom": 13.696923076923076, + "pitch": 33.93846153846154, + "bearing": 23.039999999999967 + }, + { + "frame": 2, + "center": [ + -5.454680854842297, + 55.454199187343335 + ], + "zoom": 13.795384615384616, + "pitch": 35.90769230769231, + "bearing": 34.55999999999994 + }, + { + "frame": 3, + "center": [ + -5.449154714432449, + 55.45161563359928 + ], + "zoom": 13.893846153846155, + "pitch": 37.87692307692308, + "bearing": 46.08000000000004 + }, + { + "frame": 4, + "center": [ + -5.4440854476088205, + 55.448695922972746 + ], + "zoom": 13.992307692307692, + "pitch": 39.84615384615384, + "bearing": 57.5999999999999 + }, + { + "frame": 5, + "center": [ + -5.439747931704942, + 55.4455842681447 + ], + "zoom": 14.09076923076923, + "pitch": 41.8153846153846, + "bearing": 69.11999999999988 + }, + { + "frame": 6, + "center": [ + -5.436381601488342, + 55.44242356593315 + ], + "zoom": 14.189230769230766, + "pitch": 43.78461538461536, + "bearing": 80.63999999999987 + }, + { + "frame": 7, + "center": [ + -5.434285966180362, + 55.43932695213108 + ], + "zoom": 14.287692307692307, + "pitch": 45.753846153846105, + "bearing": 92.15999999999974 + }, + { + "frame": 8, + "center": [ + -5.433268915702869, + 55.43589535070747 + ], + "zoom": 14.386153846153844, + "pitch": 47.723076923076874, + "bearing": 103.67999999999961 + }, + { + "frame": 9, + "center": [ + -5.433177455811488, + 55.43198983159487 + ], + "zoom": 14.484615384615381, + "pitch": 49.69230769230762, + "bearing": 115.19999999999958 + }, + { + "frame": 10, + "center": [ + -5.433880699537133, + 55.42784291729489 + ], + "zoom": 14.577142857142853, + "pitch": 51.54285714285707, + "bearing": 126.71999999999957 + }, + { + "frame": 11, + "center": [ + -5.435326079044055, + 55.423654806187365 + ], + "zoom": 14.668571428571424, + "pitch": 53.37142857142849, + "bearing": 138.23999999999944 + }, + { + "frame": 12, + "center": [ + -5.437416792145254, + 55.419638086917715 + ], + "zoom": 14.759999999999996, + "pitch": 55.1999999999999, + "bearing": 149.75999999999942 + }, + { + "frame": 13, + "center": [ + -5.440072094355931, + 55.41599788270592 + ], + "zoom": 14.851428571428567, + "pitch": 57.028571428571325, + "bearing": 161.2799999999993 + }, + { + "frame": 14, + "center": [ + -5.4432174520051015, + 55.412956866373364 + ], + "zoom": 14.942857142857138, + "pitch": 58.85714285714275, + "bearing": 172.79999999999927 + }, + { + "frame": 15, + "center": [ + -5.446829788111814, + 55.41064796034502 + ], + "zoom": 14.963076923076928, + "pitch": 59.26153846153856, + "bearing": -175.5692307692314 + }, + { + "frame": 16, + "center": [ + -5.451519315760324, + 55.40879433251729 + ], + "zoom": 14.864615384615387, + "pitch": 57.292307692307745, + "bearing": -163.75384615384647 + }, + { + "frame": 17, + "center": [ + -5.457134643435197, + 55.407280629375585 + ], + "zoom": 14.766153846153847, + "pitch": 55.32307692307692, + "bearing": -151.9384615384615 + }, + { + "frame": 18, + "center": [ + -5.463405013806986, + 55.40615466316898 + ], + "zoom": 14.667692307692304, + "pitch": 53.3538461538461, + "bearing": -140.1230769230766 + }, + { + "frame": 19, + "center": [ + -5.469987343492665, + 55.405428197723374 + ], + "zoom": 14.569230769230764, + "pitch": 51.38461538461526, + "bearing": -128.30769230769158 + } +] \ No newline at end of file diff --git a/e2e/tests/helpers/fetchGeojson.ts b/e2e/tests/helpers/fetchGeojson.ts new file mode 100644 index 00000000..1d190c23 --- /dev/null +++ b/e2e/tests/helpers/fetchGeojson.ts @@ -0,0 +1,21 @@ +import { KeyframeableGeoJSONFeature } from "../../../src"; + +type GeoJSON = { + type: "FeatureCollection"; + features: KeyframeableGeoJSONFeature[]; +}; + +export default async function fetchGeoJSON(assetUrl: string): Promise { + try { + const response = await fetch(assetUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch GeoJSON: ${response.statusText}`); + } + + const geojson = await response.json(); + return geojson as GeoJSON; + } catch (e) { + throw e; + } +} diff --git a/e2e/tests/helpers/getMapInstanceForFixture.ts b/e2e/tests/helpers/getMapInstanceForFixture.ts new file mode 100644 index 00000000..f67dee0f --- /dev/null +++ b/e2e/tests/helpers/getMapInstanceForFixture.ts @@ -0,0 +1,87 @@ +import { Page, expect } from "@playwright/test"; +import path from "path"; +import { injectGlobalVariables } from "./injectGlobalVariables"; + +interface IgetMapInstanceForFixture { + fixture: string; + page: Page; + mockStyle?: boolean; + mockTiles?: boolean; + debug?: boolean; + timeout?: number; +} + +export default async function getMapInstanceForFixture({ fixture, page, mockStyle = true, mockTiles = true, debug = false, timeout = 10000 }: IgetMapInstanceForFixture) { + await injectGlobalVariables(page); + await page.addInitScript((plt) => { + window.__pageLoadTimeout = plt; + }, timeout); + + if (mockStyle) { + // mock style response + await page.route("https://api.maptiler.com/maps/*/*.json*", async (route) => { + if (debug) console.info(`ℹ️ Style intercepted at ${route.request().url()}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + path: path.resolve(import.meta.dirname, "../mocks/maptiler-style.json"), + }); + }); + } + + if (mockTiles) { + // mocks the tile response always returning the mock tile + await page.route("https://api.maptiler.com/tiles/*/*/*/*.jpg?key=*&*", (route) => { + if (debug) console.info(`ℹ️ Tile intercepted at ${route.request().url()}`); + return route.fulfill({ + status: 200, + contentType: "image/png", + path: path.resolve(import.meta.dirname, "../mocks/tile.png"), + }); + }); + } + + page.on("console", (msg) => { + console.log("FIXTURE LOG:", msg.text()); + if (debug) { + console.log("DEBUG FIXTURE LOG:", msg.location(), msg.text()); + } + }); + + page.addListener("requestfinished", async (request) => { + const response = await request.response(); + if (response && response.status() >= 400) { + console.error(`\n\nFailed to load ${request.url()}\n status: ${response.status()}\n\n`); + expect(response.status()).toBeLessThan(400); + } + }); + + await page.goto(`http://localhost:5173/${fixture}.html`, { + waitUntil: "domcontentloaded", + }); + + try { + const map = await page.evaluateHandle(() => { + return Promise.race([ + new Promise(async (resolve) => { + console.log("Window.__map", window.__map); + window.__map.on("idle", () => {; + resolve(window.__map); + }); + }), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Map did not load in time")); + }, window.__pageLoadTimeout); + }), + ]); + }); + + return { + map, + }; + } catch (e) { + console.error(e); + return {}; + } +} diff --git a/e2e/tests/helpers/injectGlobalVariables.ts b/e2e/tests/helpers/injectGlobalVariables.ts new file mode 100644 index 00000000..f326329f --- /dev/null +++ b/e2e/tests/helpers/injectGlobalVariables.ts @@ -0,0 +1,13 @@ +import { Page } from "@playwright/test"; +import packagejson from "../../../package.json" assert { type: "json" }; + +export async function injectGlobalVariables(page: Page) { + console.log("injecting global variables"); + await page.addInitScript( + ({ version, nodeEnv }) => { + window.__MT_SDK_VERSION__ = version; + window.__MT_NODE_ENV__ = nodeEnv; + }, + { version: packagejson.version, nodeEnv: process.env.NODE_ENV }, + ); +} diff --git a/e2e/tests/helpers/loadFixtureAndGetMapHandle.ts b/e2e/tests/helpers/loadFixtureAndGetMapHandle.ts index d3e9d848..1c9d3fc7 100644 --- a/e2e/tests/helpers/loadFixtureAndGetMapHandle.ts +++ b/e2e/tests/helpers/loadFixtureAndGetMapHandle.ts @@ -1,7 +1,7 @@ import type { Map } from "../../../src/index"; import { JSHandle, Page, expect } from "@playwright/test"; import path from "path"; - +import { injectGlobalVariables } from "./injectGlobalVariables"; interface IloadFixtureAndGetMapHandle { fixture: string; page: Page; @@ -17,47 +17,48 @@ export default async function loadFixtureAndGetMapHandle({ mockStyle = true, mockTiles = true, debug = false, - waitUntil = 'load', + waitUntil = "load", }: IloadFixtureAndGetMapHandle): Promise<{ mapHandle: JSHandle }> { + await injectGlobalVariables(page); if (mockStyle) { // mock style response - await page.route('https://api.maptiler.com/maps/*/*.json*', async route => { - if (debug) console.info(`ℹ️ Style intercepted at ${route.request().url()}`) + await page.route("https://api.maptiler.com/maps/*/*.json*", async (route) => { + if (debug) console.info(`ℹ️ Style intercepted at ${route.request().url()}`); await route.fulfill({ status: 200, - contentType: 'application/json', - path: path.resolve(import.meta.dirname, '../mocks/maptiler-style.json'), + contentType: "application/json", + path: path.resolve(import.meta.dirname, "../mocks/maptiler-style.json"), }); }); } if (mockTiles) { // mocks the tile response always returning the mock tile - await page.route('https://api.maptiler.com/tiles/*/*/*/*.jpg?key=*&*', route => { - if (debug) console.info(`ℹ️ Tile intercepted at ${route.request().url()}`) + await page.route("https://api.maptiler.com/tiles/*/*/*/*.jpg?key=*&*", (route) => { + if (debug) console.info(`ℹ️ Tile intercepted at ${route.request().url()}`); return route.fulfill({ status: 200, - contentType: 'image/png', + contentType: "image/png", path: path.resolve(import.meta.dirname, `../mocks/tile.png`), }); }); } - - page.on('console', msg => { - console.log('FIXTURE LOG:', msg.text()); + + page.on("console", (msg) => { + console.log("FIXTURE LOG:", msg.text()); if (debug) { - console.log('DEBUG FIXTURE LOG:', msg.location(), msg.text()); + console.log("DEBUG FIXTURE LOG:", msg.location(), msg.text()); } - }) - - page.addListener('requestfinished', async (request) => { - const response = await request.response() + }); + + page.addListener("requestfinished", async (request) => { + const response = await request.response(); if (response && response.status() >= 400) { console.error(`\n\nFailed to load ${request.url()}\n status: ${response.status()}\n\n`); expect(response.status()).toBeLessThan(400); } - }) - + }); + await page.goto(`http://localhost:5173/${fixture}.html`, { waitUntil, }); @@ -65,33 +66,33 @@ export default async function loadFixtureAndGetMapHandle({ try { const mapHandle = await page.evaluateHandle(() => { return Promise.race([ - new Promise(async (resolve) => { - try { - window.__map.on("idle", ()=> { - resolve(window.__map as Map); - }) - } catch (e) { - console.error('Error getting map instance', e); - resolve(null) - } - }), - new Promise((resolve) => { - setTimeout(() => { - console.error('Map did not load in time'); - resolve(null); - }, 10000); - }) - ]) + new Promise(async (resolve) => { + try { + window.__map.on("idle", () => { + resolve(window.__map as Map); + }); + } catch (e) { + console.error("Error getting map instance", e); + resolve(null); + } + }), + new Promise((resolve) => { + setTimeout(() => { + console.error("Map did not load in time"); + resolve(null); + }, 10000); + }), + ]); }); - + return { mapHandle, - } - } catch(e) { + }; + } catch (e) { console.error(e); - const nullMap = await page.evaluateHandle(() => null) + const nullMap = await page.evaluateHandle(() => null); return { mapHandle: nullMap as JSHandle, - } + }; } -} \ No newline at end of file +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index a87cedb7..3e042a0b 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -9,7 +9,7 @@ }, "include": [ "./tests/**/*.test.ts", - "./global.d.ts", + "./tests/helpers/*.ts", "./src/**/*.ts", "../src/**/*.d.ts" ] diff --git a/images/animate-elastic-trimmed.gif b/images/animate-elastic-trimmed.gif new file mode 100644 index 00000000..59e20945 Binary files /dev/null and b/images/animate-elastic-trimmed.gif differ diff --git a/images/animate-linear-trimmed.gif b/images/animate-linear-trimmed.gif new file mode 100644 index 00000000..78008ec9 Binary files /dev/null and b/images/animate-linear-trimmed.gif differ diff --git a/package-lock.json b/package-lock.json index b790fe16..cfbb8165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@canvas/image-data": "^1.0.0", "@eslint/js": "^9.21.0", + "@maptiler/3d": "^3.1.0", "@playwright/test": "^1.51.0", "@types/color-convert": "^2.0.4", "@types/color-name": "^2.0.0", @@ -899,6 +900,27 @@ "supercluster": "^8.0.1" } }, + "node_modules/@maptiler/3d": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@maptiler/3d/-/3d-3.1.0.tgz", + "integrity": "sha512-QUJlcxdITZad4swLgD1ecyoz9MYexzL14AC8VjhusFvPxik932dGvzBN0auTkAwHI4NNhZ3vbnfTGj/B5yhWng==", + "dev": true, + "dependencies": { + "@maptiler/sdk": "^3.7.0", + "lru-cache": "^11.0.2", + "three": "~0.172.0" + } + }, + "node_modules/@maptiler/3d/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@maptiler/client": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@maptiler/client/-/client-2.6.0.tgz", @@ -908,6 +930,81 @@ "quick-lru": "^7.0.0" } }, + "node_modules/@maptiler/sdk": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@maptiler/sdk/-/sdk-3.9.0.tgz", + "integrity": "sha512-D4GNOFTq4n7jxN+7lad9cHyRJlGWCBwqWNqROATdkymbaDpwT1Q0aT6ml5pAEszpcEMvoxFfVv2QacjZz529lg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "~23.3.0", + "@maptiler/client": "~2.6.0", + "events": "^3.3.0", + "gl-matrix": "^3.4.3", + "js-base64": "^3.7.7", + "maplibre-gl": "~5.6.0", + "uuid": "^11.0.5" + } + }, + "node_modules/@maptiler/sdk/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "23.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.3.0.tgz", + "integrity": "sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maptiler/sdk/node_modules/maplibre-gl": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz", + "integrity": "sha512-SEqYThhUCFf6Lm0TckpgpKnto5u4JsdPYdFJb6g12VtuaFsm3nYdBO+fOmnUYddc8dXihgoGnuXvPPooUcRv5w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^23.3.0", + "@maplibre/vt-pbf": "^4.0.3", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/@microsoft/api-extractor": { "version": "7.55.2", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.55.2.tgz", @@ -5036,6 +5133,13 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index d7fcba3d..209c1266 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "devDependencies": { "@canvas/image-data": "^1.0.0", "@eslint/js": "^9.21.0", + "@maptiler/3d": "^3.1.0", "@playwright/test": "^1.51.0", "@types/color-convert": "^2.0.4", "@types/color-name": "^2.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index e3e0d612..3ff8322b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; - +import packagejson from './package.json' assert { type: 'json' }; /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -33,8 +33,13 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + launchOptions: { + env: { + __MT_SDK_VERSION__: packagejson.version, + __MT_NODE_ENV__: process.env.NODE_ENV, + } + } }, - snapshotDir: './e2e/snapshots', /* Configure projects for major browsers */ projects: [ diff --git a/src/MaptilerAnimation/AnimationManager.ts b/src/MaptilerAnimation/AnimationManager.ts new file mode 100644 index 00000000..895a542c --- /dev/null +++ b/src/MaptilerAnimation/AnimationManager.ts @@ -0,0 +1,76 @@ +import { MaptilerAnimation } from "./index"; + +/** + * Manager for handling animation lifecycle and updates. + * + * The AnimationManager provides a centralized system for registering and + * coordinating multiple animations. To avoid individual calls to request animation frame + * for each animation, it maintains an animation loop + * + * This is not a class as it never needs to be instantiated, + * it's just a singeton object. + * + * It's not called directly but used within the MaptilerAnimation class. + */ +const AnimationManager = { + animations: new Array(), + running: false, + /** + * Adds an animation to the manager. If this is the first animation added, + * it starts the animation loop. + * + * @param {MaptilerAnimation} animation - The animation to add. + */ + add(animation: MaptilerAnimation) { + this.animations.push(animation); + if (!this.running) { + this.running = true; + this.start(); + } + }, + /** + * Removes an animation from the manager. If there are no more animations, + * it stops the animation loop. + * + * @param {MaptilerAnimation} animation - The animation to remove. + */ + remove(animation: MaptilerAnimation) { + this.animations = this.animations.filter((a) => a !== animation); + if (this.animations.length === 0) { + this.stop(); + } + }, + /** + * Stops the animation loop. + */ + stop() { + this.running = false; + }, + /** + * Starts the animation loop. This function is called recursively using + * requestAnimationFrame to ensure smooth updates. + */ + start() { + if (!this.running) { + return; + } + const loop = () => { + if (this.animations.length === 0) { + this.running = false; + return; + } + + this.animations.forEach((animation) => { + if (animation.isPlaying) { + animation.updateInternal(); + } + }); + + requestAnimationFrame(loop); + }; + + loop(); + }, +}; + +export default AnimationManager; diff --git a/src/MaptilerAnimation/MaptilerAnimation.ts b/src/MaptilerAnimation/MaptilerAnimation.ts new file mode 100644 index 00000000..d44ccc93 --- /dev/null +++ b/src/MaptilerAnimation/MaptilerAnimation.ts @@ -0,0 +1,578 @@ +import { lerp, lerpArrayValues } from "./animation-helpers"; +import AnimationManager from "./AnimationManager"; +import { AnimationEventCallback, AnimationEventListenersRecord, AnimationEventTypes, AnimationEventTypesArray, EasingFunctionName, Keyframe } from "./types"; +import EasingFunctions from "./easing"; + +/** + * Configuration options for creating an animation. + * + * @interface MaptilerAnimationOptions + * @property {Keyframe[]} keyframes - The keyframes that define the animation states at various points in time. + * @property {number} duration - The total duration of the animation in milliseconds. + * @property {number} iterations - The number of times the animation should repeat. Use 0 for no repeat, or Infinity for an infinite loop. + * @property {delay} [delay] - Optional. The delay before the animation starts, in milliseconds. Defaults to 0 if not specified. + * @property {boolean} [manualMode] - Optional. If true, the animation will not be automatically managed by the animation manager + * and must be updated manually by calling update(). Defaults to false if not specified. + */ +export interface MaptilerAnimationOptions { + keyframes: Keyframe[]; + duration: number; + iterations: number; + manualMode?: boolean; + delay?: number; +} + +/** + * A keyframe in the animation sequence where null or undefined values + * are interpolated to fill in the gaps between keyframes. + * + * @extends Keyframe + * @property {Record} props - The properties to be animated at this keyframe. + * @property {EasingFunctionName} easing - The easing function to use for this keyframe. + * @property {string} id - A unique identifier for this keyframe. + */ +export type InterpolatedKeyFrame = Keyframe & { + props: Record; + easing: EasingFunctionName; + id: string; +}; + +/** + * Animation controller for keyframe-based animation sequences. + * + * MaptilerAnimation handles interpolation between keyframes, timing control, + * and event dispatching during animation playback. + * + * @example + * ```typescript + * const animation = new MaptilerAnimation({ + * keyframes: [ + * { delta: 0, props: { x: 0, y: 0 } }, + * { delta: 0.5, props: { x: 50, y: 20 } }, + * { delta: 1, props: { x: 100, y: 0 } } + * ], + * duration: 1000, // milliseconds + * iterations: 2 + * }); + * + * animation.addEventListener("timeupdate", (event) => { + * // Use interpolated property values to update something + * console.log(event.props); + * }); + * + * animation.play(); + * ``` + * + * @remarks + * The animation supports various playback controls (play, pause, stop, reset), + * time manipulation, and an event system for tracking animation progress. + * Properties missing in keyframes will be automatically interpolated. + * + * Animation events include play, pause, stop, timeupdate, iteration, and more. + * + * When not using manualMode, animations are automatically added to the AnimationManager. + */ +export default class MaptilerAnimation { + private playing: boolean = false; + + /** + * Indicates if the animation is currently playing + * @returns {boolean} - true if the animation is playing, false otherwise + */ + get isPlaying() { + return this.playing; + } + + /** + * The number of times to repeat the animation + * 0 is no repeat, Infinity is infinite repeat + */ + private iterations: number; + + private currentIteration: number = 0; + + /** An array of keyframes animations to interpolate between */ + private keyframes: InterpolatedKeyFrame[]; + + /** The current keyframe id */ + private currentKeyframe?: string; + + /**The duration of the animation in milliseconds (when playbackRate === 1) */ + readonly duration: number; + + /** + * The duration of the animation affected by the playback rate + * if playback rate is 2, the effective duration is double + */ + private effectiveDuration: number; + + /** the rate at which the animation is playing */ + private playbackRate: number; + + /** the current time in milliseconds */ + private currentTime: number; + + /** 0 start of the animation, 1 end of the animation */ + private currentDelta: number; + + /** The time at which the animation started */ + private animationStartTime: number = 0; + + /** The time at which the last frame was rendered */ + private lastFrameAt: number = 0; + + /** The delay before the animation starts */ + private delay: number = 0; + + /** The timeout ID for the delay before the animation starts */ + private delayTimeoutID?: number; + + /** The listeners added for each event */ + private listeners: AnimationEventListenersRecord = AnimationEventTypesArray.reduce((acc, type) => { + acc[type] = []; + return acc; + }, {} as AnimationEventListenersRecord); + + /** The props from the previous frame */ + private previousProps!: Record; + + constructor({ keyframes, duration, iterations, manualMode, delay }: MaptilerAnimationOptions) { + // collate all properties that are animated + const animatedProperties = keyframes + .map(({ props }: Keyframe) => { + return Object.keys(props) as string[]; + }) + .flat() + .reduce((props, prop: string) => { + if (prop && !props.includes(prop)) { + props.push(prop); + } + return props; + }, []); + + // iterate over keyframes and ensure all properties are present + // if not, add them with the value in next keyframe that has this property defined + + const keyframesWithAllProps = keyframes + // order keyframes by delta + .sort((a, b) => a.delta - b.delta) + // ensure animated properties are present in all keyframes + .map((keyframe) => { + const newProps = animatedProperties.reduce((props: Keyframe["props"], prop: string) => { + if (prop in props) { + return props; + } + return { + ...props, + // set as null to infer that this proprty + // does not have a value but will need to be + [prop]: null, + }; + }, keyframe.props); + + return { + ...keyframe, + props: newProps, + }; + }); + + // transform keyframes into a format that is easier to interpolate + const valuesForAllProps = keyframesWithAllProps + .map(({ props }) => props) + .reduce>((acc, keyframeProps) => { + for (const [prop, value] of Object.entries(keyframeProps)) { + if (!(prop in acc)) { + acc[prop] = []; + } + + acc[prop].push(value); + } + + return acc; + }, {}); + + // interpolate values for each property + const interpolatedValues = Object.entries(valuesForAllProps).reduce>((acc, [prop, values]) => { + acc[prop] = lerpArrayValues(values); + return acc; + }, {}); + + // update keyframes with interpolated values + this.keyframes = keyframesWithAllProps.map((keyframe, _) => { + return { + ...keyframe, + props: animatedProperties.reduce((props, prop) => { + props[prop] = interpolatedValues[prop][_]; + return props; + }, {}), + easing: keyframe.easing ?? "Linear", + id: crypto.randomUUID(), + } as InterpolatedKeyFrame; + }); + + this.duration = duration; + this.iterations = iterations; + this.delay = delay ?? 0; + this.playbackRate = 1; + this.effectiveDuration = duration / this.playbackRate; + this.currentTime = 0; + this.currentDelta = 0; + + // if not manually updating, add to the animation manager + if (!manualMode) { + AnimationManager.add(this); + } + } + /** + * Starts or resumes the animation + * @returns This animation instance for method chaining + * @event "play" + */ + play() { + if (this.playing) { + return this; + } + + if (this.delayTimeoutID) { + return this; + } + + const doPlay = () => { + this.playing = true; + this.animationStartTime = performance.now(); + this.lastFrameAt = this.animationStartTime; + this.emitEvent("play"); + }; + + if (this.delay > 0) { + this.delayTimeoutID = window.setTimeout(() => { + doPlay(); + this.delayTimeoutID = undefined; + }, this.delay / this.playbackRate); + } else { + doPlay(); + } + + return this; + } + + /** + * Pauses the animation + * @returns This animation instance for method chaining + * @event "pause" + */ + pause() { + this.playing = false; + this.emitEvent("pause"); + return this; + } + + /** + * Stops the animation and resets to initial state + * @returns This animation instance for method chaining + * @event "stop" + */ + stop(silent: boolean = false) { + this.playing = false; + if (!silent) this.emitEvent("stop"); + return this; + } + + /** + * Resets the animation to its initial state without stopping + * @returns This animation instance for method chaining + * @event "reset" + */ + reset(manual: boolean = true) { + this.stop(true); + window.clearTimeout(this.delayTimeoutID); + this.currentTime = 0; + this.currentDelta = this.playbackRate < 0 ? 1 : 0; + this.emitEvent("reset"); + this.update(false, true); + + if (!manual) this.play(); + + return this; + } + /** + * Updates the animation state if playing, this is used by the AnimationManager + * to update all animations in the loop + * @returns This animation instance for method chaining + */ + + updateInternal() { + if (!this.playing) { + return this; + } + return this.update(false); + } + + /** + * Updates the animation state, interpolating between keyframes + * and emitting events as necessary + * @event "timeupdate" + * @event "keyframe" + * @event "iteration" + * @event "animationend" + * @returns This animation instance for method chaining + */ + update(manual = true, ignoreIteration = false) { + const currentTime = performance.now(); + if (!ignoreIteration) { + const frameLength = manual ? 16 : currentTime - this.lastFrameAt; + const timeElapsed = currentTime - this.animationStartTime; + + this.lastFrameAt = currentTime; + + const timeDelta = timeElapsed * this.playbackRate; + + this.currentTime = timeDelta; + + this.currentDelta += frameLength / this.effectiveDuration; + } + + const { next, current } = this.getCurrentAndNextKeyFramesAtDelta(this.currentDelta); + + if (current?.id !== this.currentKeyframe) { + this.emitEvent("keyframe", current, next); + } + + this.currentKeyframe = current?.id; + + const interpolatedProps = Object.keys(current?.props ?? {}).reduce>((acc, prop) => { + if (current && next) { + const currentValue = current.props[prop]; + const nextValue = next.props[prop]; + + // get the current step in the interpolation + // eg 0 = current keyframe, 1 = next keyframe + const t = (this.currentDelta - current.delta) / (next.delta - current.delta); + + // get the easing function to use + const easingFunc = EasingFunctions[current.easing] ?? ((n: number) => n); + + // get the alpha value from the easing function + // this value is the amount to interpolate between + // the current and next value + const alpha = easingFunc(t); + + // lerp the value, becuase we are lerping between + // two values, we can use the alpha value to determine + // how much of each value to use + acc[prop] = lerp(currentValue, nextValue, alpha); + } + + if (current && !next) { + acc[prop] = current.props[prop]; + } + return acc; + }, {}); + if (!this.previousProps) { + this.previousProps = this.keyframes[0].props; + } + + this.emitEvent("timeupdate", current, next, interpolatedProps, this.previousProps); + + if ((this.currentDelta >= 1 || this.currentDelta < 0) && !ignoreIteration) { + this.currentIteration += 1; + this.emitEvent("iteration", null, null, {}); + if (this.iterations === 0 || this.currentIteration < this.iterations) { + this.reset(manual); + return this; + } + + this.stop(); + this.emitEvent("animationend"); + return this; + } + + this.previousProps = { ...interpolatedProps }; + + return this; + } + + /** + * Gets the current and next keyframes at a specific time + * @param time - The time position to query + * @returns Object containing current and next keyframes, which may be null + */ + getCurrentAndNextKeyFramesAtTime(time: number) { + return this.getCurrentAndNextKeyFramesAtDelta(time / this.effectiveDuration); + } + + /** + * Gets the current and next keyframes at a specific delta value + * @param delta - The delta value to query + * @returns Object containing current and next keyframes, which may be null + */ + getCurrentAndNextKeyFramesAtDelta(delta: number) { + const next = this.keyframes.find((keyframe) => keyframe.delta > delta) ?? null; + const current = this.keyframes.findLast((keyframe) => keyframe.delta <= delta) ?? null; + + return { current, next }; + } + + /** + * Gets the current time position of the animation + * @returns The current time in milliseconds + */ + getCurrentTime() { + return this.currentTime; + } + + /** + * Sets the current time position of the animation + * @param time - The time to set in milliseconds + * @returns This animation instance for method chaining + * @throws Error if time is greater than the duration + * @event "scrub" + */ + setCurrentTime(time: number) { + if (time > this.effectiveDuration) { + throw new Error(`Cannot set time greater than duration`); + } + + this.currentTime = time; + this.currentDelta = time / this.effectiveDuration; + this.emitEvent("scrub"); + return this; + } + + /** + * Gets the current delta value of the animation + * @returns The current delta value (normalized progress between 0 and 1) + */ + getCurrentDelta() { + return this.currentDelta; + } + + /** + * Sets the current delta value of the animation + * @param delta - The delta value to set (normalized progress between 0 and 1) + * @returns This animation instance for method chaining + * @throws Error if delta is greater than 1 + * @event "scrub" + */ + setCurrentDelta(delta: number) { + if (delta > 1) { + throw new Error(`Cannot set delta greater than 1`); + } + + this.animationStartTime = performance.now(); + this.lastFrameAt = this.animationStartTime; + + this.currentDelta = delta; + this.currentTime = delta * this.effectiveDuration; + + this.update(false, true); + + this.emitEvent("scrub"); + return this; + } + + /** + * Sets the playback rate of the animation + * @param rate - The playback rate (1.0 is normal speed) + * @returns This animation instance for method chaining + * @event "playbackratechange" + */ + setPlaybackRate(rate: number) { + this.playbackRate = rate; + this.effectiveDuration = this.duration / this.playbackRate; + this.emitEvent("playbackratechange"); + return this; + } + + /** + * Gets the current playback rate + * @returns The current playback rate + */ + getPlaybackRate() { + return this.playbackRate; + } + + /** + * Adds an event listener to the animation + * @param type - The type of event to listen for + * @param callback - The callback function to execute when the event occurs + * @returns This animation instance for method chaining + */ + addEventListener(type: AnimationEventTypes, callback: AnimationEventCallback) { + // "value is always falsy" - TS + // this is to catch any dynamically set events + if (!(type in this.listeners)) { + console.warn(`Event type ${type} does not exist, ignoring`); + return this; + } + + this.listeners[type].push(callback); + + return this; + } + + /** + * Removes an event listener from the animation + * @param type - The type of event to remove + * @param callback - The callback function to remove + * @returns This animation instance for method chaining + */ + removeEventListener(type: AnimationEventTypes, callback: AnimationEventCallback) { + // "value is always falsy" - TS + // this is to catch any dynamically set events + if (!(type in this.listeners)) { + console.warn(`Event type ${type} does not exist, ignoring`); + return this; + } + + this.listeners[type] = this.listeners[type].filter((fn) => fn !== callback); + + return this; + } + + /** + * Emits an event to all listeners of a specific type + * @param event - The type of event to emit + * @param keyframe - The keyframe that triggered the event + * @param props - The interpolated properties at the current delta + */ + emitEvent(event: AnimationEventTypes, keyframe?: Keyframe | null, nextKeyframe?: Keyframe | null, props: Record = {}, previousProps?: Record) { + this.listeners[event].forEach((fn: AnimationEventCallback) => { + fn({ + type: event, + target: this, + currentTime: this.currentTime, + currentDelta: this.currentDelta, + playbackRate: this.playbackRate, + keyframe, + nextKeyframe: nextKeyframe ?? keyframe, + props, + previousProps: previousProps ?? props, + }); + }); + } + + /** + * Creates a clone of this animation + * @returns A new animation instance with the same properties as this one + */ + clone() { + return new MaptilerAnimation({ + keyframes: structuredClone(this.keyframes), + duration: this.duration, + iterations: this.iterations, + }); + } + + /** + * Destroys the animation instance, removing all event listeners and stopping playback + */ + destroy() { + this.stop(); + this.listeners = AnimationEventTypesArray.reduce((acc, type) => { + acc[type] = []; + return acc; + }, {} as AnimationEventListenersRecord); + AnimationManager.remove(this); + } +} diff --git a/src/MaptilerAnimation/animation-helpers.ts b/src/MaptilerAnimation/animation-helpers.ts new file mode 100644 index 00000000..a7b220cc --- /dev/null +++ b/src/MaptilerAnimation/animation-helpers.ts @@ -0,0 +1,500 @@ +import { Feature, LineString, MultiLineString, MultiPoint, Polygon } from "geojson"; +import { EasingFunctionName, Keyframe, NumericArrayWithNull } from "./types"; +import { arraysAreTheSameLength } from "../utils/array"; +import { LngLat } from "../"; + +/** + * Performs simple linear interpolation between two numbers. + * + * @param a - The start value + * @param b - The end value + * @param alpha - The interpolation factor (typically between 0 and 1): + * 0 returns a, 1 returns b, and values in between return a proportional mix + * @returns The interpolated value between a and b + */ +export function lerp(a: number, b: number, alpha: number) { + return a + (b - a) * alpha; +} + +/** + * Interpolates an array of numbers, replacing null values with interpolated values. + * + * `null` is treated as an empty value where an interpolation is needed. + * + * @param {NumericArrayWithNull} numericArray - The array of numbers to interpolate, which may contain null values + * @returns A new array with null values replaced by interpolated values + */ +export function lerpArrayValues(numericArray: NumericArrayWithNull): number[] { + if (numericArray.length === 0) { + throw new Error("[lerpArrayValues]: Array empty, nothing to interpolate"); + } + + if (numericArray.every((value) => value === null)) { + throw new Error("[lerpArrayValues]: Cannot interpolate an array where all values are `null`"); + } + + return numericArray.map((value, index, arr): number => { + // if value is a number, return it + if (typeof value === "number") { + return value; + } + + const [prevIndex, prevValue] = findPreviousEntryAndIndexWithValue(arr, index); + + const [nextIndex, nextValue] = findNextEntryAndIndexWithValue(arr, index); + + // if there is no previous value, eg all values are null before this index + // return the value of the next entry that has a value + // "fill all the way to the start" + if (prevIndex === null || prevValue === null) { + return arr[index + 1]!; + } + + // if there is no next value, eg all values are null after this index + // return the value of the previous entry that has a value + // "fill all the way to the end" + if (nextIndex === null || nextValue === null) { + return prevValue; + } + + // this means that anything else is null that sits between + // two values that are not null, meaning we can interpolate + const alpha = (index - prevIndex) / (nextIndex - prevIndex); + + return lerp(prevValue, nextValue, alpha); + }); +} + +/** + * Looks ahead in an array for the next entry that is not null. + * + * @param arr - The array to search through + * @param currentIndex - The index to start searching from + * @returns [index, value] A tuple containing the index of the next entry and its value, or null if not found + */ +export function findNextEntryAndIndexWithValue(arr: NumericArrayWithNull, currentIndex: number) { + for (let i = currentIndex + 1; i < arr.length; i++) { + if (arr[i] !== null) { + return [i, arr[i]]; + } + } + return [null, null]; +} + +/** + * Looks back in an array for the previous entry that is not null. + * + * @param arr - The array to search through + * @param currentIndex - The index to start searching from + * @returns [index, value] A tuple containing the index of the previous entry and its value, or null if not found + */ +export function findPreviousEntryAndIndexWithValue(arr: NumericArrayWithNull, currentIndex: number) { + for (let i = currentIndex - 1; i >= 0; i--) { + if (arr[i] !== null) { + return [i, arr[i]]; + } + } + return [null, null]; +} + +/** + * Options for parsing GeoJSON data into animation keyframes. + * + * @interface ParseGeoJSONToKeyframesOptions + * @property {EasingFunctionName} [defaultEasing] - The default easing function to apply to the animation. + * @property {Object|false} [pathSmoothing] - Configuration for path smoothing, or false to disable smoothing. + * @property {number} [pathSmoothing.resolution] - The resolution used for path smoothing, higher values produce more detailed paths. + * @property {number} [pathSmoothing.epsilon] - Optional tolerance parameter for path simplification. + */ +export interface ParseGeoJSONToKeyframesOptions { + defaultEasing?: EasingFunctionName; + pathSmoothing?: + | { + resolution: number; + epsilon?: number; + } + | false; +} + +const defaultOptions: ParseGeoJSONToKeyframesOptions = { + defaultEasing: "Linear", + pathSmoothing: { + resolution: 20, + epsilon: 5, + }, +}; + +const ACCEPTED_GEOMETRY_TYPES = ["MultiPoint", "LineString", "MultiLineString", "Polygon"]; + +/** + * Represents geometry types that can be animated using keyframes. + * + * This type includes geometries like MultiPoint, LineString, MultiLineString, and Polygon + * which can be interpolated or transformed over time in animations. + */ +export type KeyframeableGeometry = MultiPoint | LineString | MultiLineString | Polygon; + +/** + * Represents a GeoJSON Feature that can be animated with keyframes. + * Extends the standard GeoJSON Feature with animation-specific properties. + * + * @typedef {Object} KeyframeableGeoJSONFeature + * @extends {Feature} + * @property {Object} properties - Contains both standard GeoJSON properties (as number arrays) and animation controls + * @property {EasingFunctionName[]} [properties.@easing] - Easing functions to apply to property animations + * @property {number[]} [properties.@delta] - Delta values for animation calculations + * @property {number} [properties.@duration] - Duration of the animation in milliseconds + * @property {number} [properties.@delay] - Delay before animation starts in milliseconds + * @property {number} [properties.@iterations] - Number of times the animation should repeat + * @property {boolean} [properties.@autoplay] - Whether the animation should start automatically + */ +export type KeyframeableGeoJSONFeature = Feature & { + properties: Record & { + "@easing"?: EasingFunctionName[]; + "@delta"?: number[]; + "@duration"?: number; + "@delay"?: number; + "@iterations"?: number; + "@autoplay"?: boolean; + altitude?: (number | null)[]; + }; +}; + +/** + * Converts a GeoJSON feature into an array of animation keyframes. + * + * Parses a GeoJSON feature with reserved properties to create animation keyframes. + * It extracts coordinates from the geometry and uses properties prefixed with '@' as animation + * control parameters (easing functions, delta values). Non-reserved properties are preserved + * and passed to the keyframe objects as props that will be interpolated + * + * @param {KeyframeableGeoJSONFeature} feature - The GeoJSON feature to convert to keyframes + * @param {ParseGeoJSONToKeyframesOptions} options - Configuration options + * + * @returns Array of keyframe objects that can be used for animation + * + * @throws {Error} When no geometry is found in the feature + * @throws {Error} When the geometry type is not supported + */ +export function parseGeoJSONFeatureToKeyframes(feature: KeyframeableGeoJSONFeature, options: ParseGeoJSONToKeyframesOptions = {}): Keyframe[] { + const { defaultEasing, pathSmoothing } = { + ...defaultOptions, + ...options, + } as ParseGeoJSONToKeyframesOptions; + + const geometry = feature.geometry; + const properties = feature.properties ?? {}; + + const easings = properties["@easing"]; + + if (!easings) { + console.warn(`[parseGeoJSONFeatureToKeyframes]: No '@easing' property found in GeoJSON properties, using default easing ${defaultEasing as string}`); + } + + const deltas = properties["@delta"]; + if (!deltas) { + console.warn(`[parseGeoJSONFeatureToKeyframes]: No '@delta' property found in GeoJSON properties, delta for each frame will default to its index divided by the total`); + } + + if (!geometry.type) { + throw new Error("[parseGeoJSONFeatureToKeyframes]: No geometry found in feature"); + } + + if (!ACCEPTED_GEOMETRY_TYPES.includes(geometry.type)) { + throw new Error(`[parseGeoJSONFeatureToKeyframes]: Geometry type '${geometry.type}' is not supported. Accepted types are: ${ACCEPTED_GEOMETRY_TYPES.join(", ")}`); + } + + // if the geometry is an array of arrays of coordinates + // we need to flatten it to a single array of coordinates + const flattenGeometry = geometry.type !== "LineString" && geometry.type !== "MultiPoint"; + + // for now we flatten the geometry to a single array + // this means that polygons with holes will be treated as a single array + const parseableGeometry = flattenGeometry ? geometry.coordinates.flat() : geometry.coordinates; + + const altitude = parseableGeometry.map((coordinate) => { + if (coordinate.length > 2) { + return coordinate[2]; + } + return null; + }); + + // if the altitude is an array of `null`, it cannot be interpolated so it's ignored + const altitudeIsEntirelyNull = altitude.every((value) => value === null); + + // extract the properties that are not reserved for animation control + const nonReservedProperties = Object.entries({ + ...properties, + ...(!altitudeIsEntirelyNull && { altitude }), + }).reduce((acc, [key, value]) => { + if (key.startsWith("@")) { + return acc; + } + return { + ...acc, + [key]: value, + }; + }, {}); + + const parseableDeltas = deltas ?? parseableGeometry.map((_, index) => index / parseableGeometry.length); + const parseableEasings = easings ?? parseableDeltas.map(() => defaultEasing ?? "Linear"); + + // if smoothing options are provided, we need to smooth the path + // this is generally used for camera animations to avoid jerky motion + // NOTE: any 3rd "altitude" coordinate is ignored for smoothing + // this means that the path will be smoothed only in 2D space + if (parseableGeometry.some((coordinate) => coordinate.length > 2)) { + console.warn("[parseGeoJSONFeatureToKeyframes]: Smoothing is not supported for 3D paths, only 2D smoothing will be applied, ignoring altitude"); + } + + if (pathSmoothing) { + const smoothedPath = createBezierPathFromCoordinates(parseableGeometry as [number, number][], pathSmoothing.resolution, pathSmoothing.epsilon); + const smoothedDeltas = smoothedPath.map((_, index) => index / smoothedPath.length); + const smoothedEasings = smoothedDeltas.map(() => defaultEasing ?? "Linear"); + + const smoothedProperties = Object.entries(nonReservedProperties as Record).reduce((acc, [key, value]) => { + const newArrayLength = smoothedPath.length; + + // "stretch" the array to the new length + // this means that if the smoothed path array is longer than the old one + // we need to fill the new array with the old values and leave `null` + // for interpolation between those values + const newArray = stretchNumericalArray(value, newArrayLength); + + return { + ...acc, + [key]: newArray, + }; + }, {}); + + // pass the arguments to getKeyframes + // this will return an array of keyframes with the smoothed path + return getKeyframes(smoothedPath, smoothedDeltas, smoothedEasings, smoothedProperties); + } + + // if the path smoothing is not applied, we pass the original coordinates + return getKeyframes(parseableGeometry, parseableDeltas, parseableEasings, nonReservedProperties); +} + +/** + * + * "Stretches" an array of numbers to a new length, filling in null values for the new indices. + * + * @param source the array to stretch + * @param targetLength the length to stretch to + * @returns {number[]} the stretched array with null values + */ +export function stretchNumericalArray(source: number[], targetLength: number): (number | null)[] { + const mapOfIndices = source.map((_, i) => { + const t = i / (source.length - 1); + return Math.round(t * (targetLength - 1)); + }); + + return Array.from({ length: targetLength }, (_, i) => (mapOfIndices.includes(i) ? source[mapOfIndices.indexOf(i)] : null)); +} + +/** + * Generates an array of keyframes from coordinates, deltas, easings, and other properties provided by the geoJSON feature. + * Assumes that the coordinates are in the format [longitude, latitude, altitude?]. If altitude is not provided, it defaults to 0. + * + * @param coordinates - Array of coordinate points, where each point is an array of [longitude, latitude, altitude?] + * @param deltas - Array of time deltas between keyframes + * @param easings - Array of easing function names to apply to each keyframe transition + * @param properties - Optional additional properties as key-value pairs, where each value is an array + * of numbers corresponding to each keyframe + * + * @returns An array of Keyframe objects, each containing coordinate props, delta, and easing information + * + * @throws Error if the arrays for coordinates, deltas, easings, and any property values don't have matching lengths + * + * @example + * const keyframes = getKeyframes( + * [[0, 0, 10], [10, 10, 20]], // coordinates + * [1000, 2000], // deltas (in milliseconds) + * ["Linear", "ElasticIn"], // easings + * { zoom: [10, 15] } // additional properties + * ); + */ +export function getKeyframes(coordinates: number[][], deltas: number[], easings: EasingFunctionName[], properties: Record = {}): Keyframe[] { + if (!arraysAreTheSameLength(coordinates, deltas, easings, ...Object.values(properties))) { + throw new Error(` + [parseGeoJSONFeatureToKeyframes]: If smoothing is not applied, coordinates, deltas, easings and property arrays must be the same length\n + Coordinates: ${coordinates.length}\n + Deltas: ${deltas.length}\n + Easing: ${easings.length}\n + Properties: ${Object.entries(properties) + .map(([key, value]) => `"${key}": ${value.length}`) + .join(", ")} + `); + } + + const includeAltitude = coordinates.some((coordinate) => coordinate.length > 2); + + return coordinates.map((coordinate: number[], index: number) => { + const delta = deltas[index]; + const easing = easings[index]; + + const propertyValuesForThisKeyframe = Object.entries(properties).reduce((acc, [key, valueArray]) => { + return { + ...acc, + [key]: valueArray[index], + }; + }, {}); + + const props = { + ...propertyValuesForThisKeyframe, + lng: coordinate[0], + lat: coordinate[1], + ...(includeAltitude && { altitude: coordinate[2] ?? null }), + }; + + return { + props, + delta, + easing, + }; + }); +} + +/** + * Creates a smoothed path using cubic Bezier curves from an array of coordinates. + * + * This function takes a series of points and creates a smooth path by generating cubic + * Bezier curves between them. It uses the Catmull-Rom method to automatically calculate + * control points for each curve segment. If the path has fewer than 4 points, the original + * path is returned unchanged. + * + * @param inputPath - Array of [x, y] coordinates that define the original path + * @param outputResolution - Controls how many points are generated along each segment + * (higher values create smoother curves with more points) + * @param simplificationThreshold - Optional threshold for simplifying the input path before + * creating the curves. + * @returns An array of [x, y] coordinates representing the smoothed path + */ +export function createBezierPathFromCoordinates(inputPath: [number, number][], outputResolution: number = 20, simplificationThreshold?: number): [number, number][] { + const path = typeof simplificationThreshold === "number" ? simplifyPath(inputPath, simplificationThreshold) : inputPath; + + if (path.length < 4) return path; // Need at least 4 points + + const smoothPath: [number, number][] = []; + + for (let i = 1; i < path.length - 2; i++) { + const p0 = path[i - 1]; + const p1 = path[i]; + const p2 = path[i + 1]; + const p3 = path[i + 2]; + + // Compute control points... + const c1: [number, number] = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; + const c2: [number, number] = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + + // Generate points along the curve... + for (let t = 0; t <= 1; t += 1 / outputResolution) { + const x = (1 - t) ** 3 * p1[0] + 3 * (1 - t) ** 2 * t * c1[0] + 3 * (1 - t) * t ** 2 * c2[0] + t ** 3 * p2[0]; + + const y = (1 - t) ** 3 * p1[1] + 3 * (1 - t) ** 2 * t * c1[1] + 3 * (1 - t) * t ** 2 * c2[1] + t ** 3 * p2[1]; + + smoothPath.push([x, y]); + } + } + + return smoothPath; +} + +/** + * Calculates the average distance between points in an array of coordinates. + * + * This function computes the average distance between consecutive points in the array. + * It uses the LngLat class from MapLibre to calculate distances based on geographical coordinates. + * + * @param arr - An array of coordinate pairs [longitude, latitude] + * @returns The average distance between points in the array + */ +export function getAverageDistance(arr: [number, number][]): number { + return ( + arr + .map((point, index) => { + if (index === 0) return 0; + const lastPoint = arr[index - 1]; + const lngLat = new LngLat(point[0], point[1]); + const lastLngLat = new LngLat(lastPoint[0], lastPoint[1]); + return lngLat.distanceTo(lastLngLat); + }) + .reduce((acc, dist) => acc + dist, 0) / arr.length + ); +} + +/** + * Simplfies a path by removing points that are too close together. + * + * This function first resamples the path based on the average distance between points, + * then filters out points that are closer than the specified distance from the last included point. + * The first and last points of the original path are always preserved. + * + * @param points - An array of coordinate pairs [longitude, latitude] + * @param distance - The minimum distance between points in the simplified path + * @returns A new array containing a simplified version of the input path + */ +export function simplifyPath(points: [number, number][], distance: number): [number, number][] { + const path = resamplePath(points, getAverageDistance(points) * distance); + + if (path.length < 2) return path; + + const simplifiedPath: [number, number][] = [path[0]]; // Start with the first point + + let lastPoint = path[0]; + + for (let i = 1; i < path.length; i++) { + const currentPoint = path[i]; + + const lastLngLat = new LngLat(lastPoint[0], lastPoint[1]); + const currentLngLat = new LngLat(currentPoint[0], currentPoint[1]); + const dist = lastLngLat.distanceTo(currentLngLat); + // Add the point if it is farther than the specified distance + if (dist >= distance) { + simplifiedPath.push(currentPoint); + lastPoint = currentPoint; + } + } + + simplifiedPath.push(path[path.length - 1]); // Add the last point + + return simplifiedPath; +} + +/** + * Resamples a geographic path to have points spaced at approximately equal distances. + * If the original path has fewer than 2 points, it is returned unchanged. + * + * @param path - An array of coordinate pairs [longitude, latitude] representing the path to resample + * @param spacing - The desired spacing between points in the resampled path (in the same unit as used by the distanceTo method), defaults to 10 + * @returns A new array of coordinate pairs representing the resampled path with approximately equal spacing between points + * + */ +export function resamplePath(path: [number, number][], spacing: number = 10): [number, number][] { + if (path.length < 2) return path; + + const result: [number, number][] = [path[0]]; + let remaining = spacing; + + for (let i = 0; i < path.length - 1; ) { + const p1 = LngLat.convert(path[i]); + const p2 = LngLat.convert(path[i + 1]); + const segDist = p1.distanceTo(p2); + + if (segDist < remaining) { + remaining -= segDist; + i++; + } else { + const t = remaining / segDist; + + const newPoint: [number, number] = [lerp(p1.lng, p2.lng, t), lerp(p1.lat, p2.lat, t)]; + + result.push(newPoint); + path[i] = newPoint; // insert newPoint into segment + remaining = spacing; + } + } + + return result; +} diff --git a/src/MaptilerAnimation/easing.ts b/src/MaptilerAnimation/easing.ts new file mode 100644 index 00000000..0ae0896c --- /dev/null +++ b/src/MaptilerAnimation/easing.ts @@ -0,0 +1,165 @@ +import { EasingFunctionName } from "./types"; + +const EasingFunctions: Record number> = { + Linear: easingLinear, + QuadraticIn: easingQuadraticIn, + QuadraticOut: easingQuadraticOut, + QuadraticInOut: easingQuadraticInOut, + CubicIn: easingCubicIn, + CubicOut: easingCubicOut, + CubicInOut: easingCubicInOut, + SinusoidalIn: easingSinusoidalIn, + SinusoidalOut: easingSinusoidalOut, + SinusoidalInOut: easingSinusoidalInOut, + ExponentialIn: easingExponentialIn, + ExponentialOut: easingExponentialOut, + ExponentialInOut: easingExponentialInOut, + ElasticIn: easingElasticIn, + ElasticOut: easingElasticOut, + ElasticInOut: easingElasticInOut, + BounceIn: easingBounceIn, + BounceOut: easingBounceOut, + BounceInOut: easingBounceInOut, +} as const; + +export default EasingFunctions; + +function easingLinear(n: number): number { + return n; +} + +function easingQuadraticIn(n: number): number { + return n * n; +} + +function easingQuadraticOut(n: number): number { + return n * (2 - n); +} + +function easingQuadraticInOut(n: number): number { + let dn = n * 2; + if (dn < 1) { + return 0.5 * dn * dn; + } + dn -= 1; + return -0.5 * (dn * (dn - 2) - 1); +} + +function easingCubicIn(n: number): number { + return n * n * n; +} + +function easingCubicOut(n: number): number { + return --n * n * n + 1; +} + +function easingCubicInOut(n: number): number { + let dn = n * 2; + if (dn < 1) { + return 0.5 * dn * dn * dn; + } + dn -= 2; + return 0.5 * (dn * dn * dn + 2); +} +function easingSinusoidalIn(n: number): number { + return 1 - Math.cos((n * Math.PI) / 2); +} + +function easingSinusoidalOut(n: number): number { + return Math.sin((n * Math.PI) / 2); +} + +function easingSinusoidalInOut(n: number): number { + return 0.5 * (1 - Math.cos(Math.PI * n)); +} +function easingExponentialIn(n: number): number { + return n === 0 ? 0 : 1024 ** (n - 1); +} + +function easingExponentialOut(n: number): number { + return n === 1 ? 1 : 1 - 2 ** (-10 * n); +} + +function easingExponentialInOut(n: number): number { + if (n === 0) return 0; + if (n === 1) return 1; + const dn = n * 2; + if (dn < 1) { + return 0.5 * 1024 ** (dn - 1); + } + return 0.5 * (-(2 ** (-10 * (dn - 1))) + 2); +} +function easingElasticIn(n: number): number { + let a = 0.1; + const p = 0.4; + let s; + if (n === 0) return 0; + if (n === 1) return 1; + if (a < 1) { + a = 1; + s = p / 4; + } else { + s = (p * Math.asin(1 / a)) / (2 * Math.PI); + } + n -= 1; + return -(a * 2 ** (10 * n) * Math.sin(((n - s) * (2 * Math.PI)) / p)); +} + +function easingElasticOut(n: number): number { + let a = 0.1; + const p = 0.4; + let s; + if (n === 0) return 0; + if (n === 1) return 1; + if (a < 1) { + a = 1; + s = p / 4; + } else { + s = (p * Math.asin(1 / a)) / (2 * Math.PI); + } + return a * 2 ** (-10 * n) * Math.sin(((n - s) * (2 * Math.PI)) / p) + 1; +} + +function easingElasticInOut(n: number): number { + let a = 0.1; + const p = 0.4; + let s; + if (n === 0) return 0; + if (n === 1) return 1; + if (a < 1) { + a = 1; + s = p / 4; + } else { + s = (p * Math.asin(1 / a)) / (2 * Math.PI); + } + const dn = n * 2; + if (dn < 1) { + const nInner = dn - 1; + return -0.5 * (a * 2 ** (10 * nInner) * Math.sin(((nInner - s) * (2 * Math.PI)) / p)); + } + const nInner = dn - 1; + return a * 2 ** (-10 * nInner) * Math.sin(((nInner - s) * (2 * Math.PI)) / p) * 0.5 + 1; +} + +function easingBounceIn(n: number): number { + return 1 - easingBounceOut(1 - n); +} + +function easingBounceOut(n: number): number { + if (n < 1 / 2.75) { + return 7.5625 * n * n; + } else if (n < 2 / 2.75) { + const n2 = n - 1.5 / 2.75; + return 7.5625 * n2 * n2 + 0.75; + } else if (n < 2.5 / 2.75) { + const n2 = n - 2.25 / 2.75; + return 7.5625 * n2 * n2 + 0.9375; + } + const n2 = n - 2.625 / 2.75; + return 7.5625 * n2 * n2 + 0.984375; +} + +function easingBounceInOut(n: number): number { + if (n < 0.5) return easingBounceIn(n * 2) * 0.5; + return easingBounceOut(n * 2 - 1) * 0.5 + 0.5; +} diff --git a/src/MaptilerAnimation/index.ts b/src/MaptilerAnimation/index.ts new file mode 100644 index 00000000..251a05c8 --- /dev/null +++ b/src/MaptilerAnimation/index.ts @@ -0,0 +1,21 @@ +import MaptilerAnimation from "./MaptilerAnimation"; +export { MaptilerAnimation }; + +export type * from "./MaptilerAnimation"; +export type * from "./types"; + +export * from "./easing"; +export { + type KeyframeableGeometry, + type KeyframeableGeoJSONFeature, + lerp, + lerpArrayValues, + parseGeoJSONFeatureToKeyframes, + createBezierPathFromCoordinates, + getAverageDistance, + simplifyPath, + resamplePath, + stretchNumericalArray, +} from "./animation-helpers"; + +export type * from "./animation-helpers"; diff --git a/src/MaptilerAnimation/types.ts b/src/MaptilerAnimation/types.ts new file mode 100644 index 00000000..8bfc04d8 --- /dev/null +++ b/src/MaptilerAnimation/types.ts @@ -0,0 +1,85 @@ +import type MaptilerAnimation from "./MaptilerAnimation"; + +export type EasingFunctionName = + | "Linear" + | "QuadraticIn" + | "QuadraticOut" + | "QuadraticInOut" + | "CubicIn" + | "CubicOut" + | "CubicInOut" + | "SinusoidalIn" + | "SinusoidalOut" + | "SinusoidalInOut" + | "ExponentialIn" + | "ExponentialOut" + | "ExponentialInOut" + | "ElasticIn" + | "ElasticOut" + | "ElasticInOut" + | "BounceIn" + | "BounceOut" + | "BounceInOut"; + +export type Keyframe = { + // the properties to interpolate between + props: Record; + + // when in the animation to apply the keyframe + // 0 start of the animation, 1 end of the animation + delta: number; + + // the easing function to use between this keyframe and the next + easing?: EasingFunctionName; + + // custom data to pass to the keyframe + userData?: Record; +}; + +export type AnimationEventTypes = + | "pause" + | "reset" + | "play" + | "stop" + | "timeupdate" + | "scrub" + | "playbackratechange" + | "animationstart" + | "animationend" + | "keyframe" + | "iteration"; + +export const AnimationEventTypesArray: AnimationEventTypes[] = [ + "pause", + "reset", + "play", + "stop", + "timeupdate", + "scrub", + "playbackratechange", + "animationstart", + "animationend", + "keyframe", + "iteration", +]; + +export type NumericArrayWithNull = (number | null)[]; + +export type AnimationEvent = { + type: AnimationEventTypes; + target: MaptilerAnimation; + currentTime: number; + currentDelta: number; + playbackRate: number; + keyframe?: Keyframe | null; + nextKeyframe?: Keyframe | null; + props: Record; + previousProps: Record; + iteration?: number; +}; + +export type AnimationEventListenersRecord = Record; + +export type AnimationEventCallback = (event: AnimationEvent) => void; + +export { MaptilerAnimation }; diff --git a/src/custom-layers/AnimatedRouteLayer/AnimatedRouteLayer.ts b/src/custom-layers/AnimatedRouteLayer/AnimatedRouteLayer.ts new file mode 100644 index 00000000..d7ece7a6 --- /dev/null +++ b/src/custom-layers/AnimatedRouteLayer/AnimatedRouteLayer.ts @@ -0,0 +1,583 @@ +import { AnimationEvent, AnimationEventListenersRecord, AnimationEventTypes, AnimationEventTypesArray, EasingFunctionName, Keyframe } from "../../MaptilerAnimation/types"; +import { v4 as uuidv4 } from "uuid"; +import { MaptilerAnimation, MaptilerAnimationOptions } from "../../MaptilerAnimation"; + +import { CustomLayerInterface, GeoJSONSource, Map as MapSDK } from "../../"; +import { KeyframeableGeoJSONFeature, parseGeoJSONFeatureToKeyframes } from "../../MaptilerAnimation/animation-helpers"; + +export type SourceData = { + id: string; + layerID: string; +}; + +/** + * Options for configuring the animated stroke effect for routes. + * When an object is provided, it defines colors for active and inactive parts of the route. + * When `false`, the animated stroke effect is disabled. + * + * @typedef {Object|boolean} AnimatedStrokeOptions + * @property {[number, number, number, number]} activeColor - The color of the path that has been progressed, in RGBA format. + * @property {[number, number, number, number]} inactiveColor - The base color of the path, in RGBA format. + */ +export type AnimatedStrokeOptions = + | { + activeColor: [number, number, number, number]; + inactiveColor: [number, number, number, number]; + } + | false; + +/** + * Options for configuring the animated camera movement + * along the route. + * + * @typedef {Object|boolean} AnimatedCameraOptions + * @property {boolean} [follow] - Whether the camera should follow the animation. + * @property {Object|boolean} [pathSmoothing] - Whether the camera path should be smoothed. + * @property {number} [pathSmoothing.resolution] - The resolution of the smoothing, higher resolution means more fidelity to the path. + * @property {number} [pathSmoothing.epsilon] - How much to simplify the path beforehand. + * @property {false} [pathSmoothing] - Whether the camera path should be smoothed. + */ +export type AnimatedCameraOptions = + | { + /** Whether the camera should follow the animation */ + follow?: boolean; + /** Whether the camera path should be smoothed */ + pathSmoothing?: + | { + /** the resolution of the smoothing, higher resolution means more fidelity to the path */ + resolution: number; + /** How mich to simplify the path beforehand */ + epsilon: number; + } + | false; + } + | false; + +/** + * Configuration options for the AnimatedRouteLayer. + * This type supports either providing keyframes directly OR source data for the animation. + * + * @typedef AnimatedRouteLayerOptions + * @property {number} [duration] - The duration of the animation in milliseconds + * @property {number} [iterations] - The number of animation iterations to perform + * @property {number} [delay] - The delay in milliseconds before starting the animation + * @property {EasingFunctionName} [easing] - The default easing function to use if not provided in the GeoJSON + * @property {AnimatedCameraOptions} [cameraAnimation] - Options for camera animation + * @property {AnimatedStrokeOptions} [pathStrokeAnimation] - Options for stroke animation, only applicable for LineString geometries + * @property {boolean} [autoplay] - Whether the animation should start playing automatically + * @property {boolean} [manualUpdate] - Whether the animation should update automatically or require manual frameAdvance calls + * @property {Keyframe[]} [keyframes] - The keyframes for the animation (mutually exclusive with source) + * @property {SourceData} [source] - The source data for the animation (mutually exclusive with keyframes) + */ +export type AnimatedRouteLayerOptions = { + /** The Duration in ms */ + duration?: number; + /** The number of iterations */ + iterations?: number; + /** The delay in ms before playing */ + delay?: number; + /** The default easing to use if not provided in teh GeoJSON */ + easing?: EasingFunctionName; + /** The camera animation options */ + cameraAnimation?: AnimatedCameraOptions; + /** The stroke animation options, only viable for LineString geometries */ + pathStrokeAnimation?: AnimatedStrokeOptions; + /** Whether the animation should autoplay */ + autoplay?: boolean; + /** Whether the animation should auto matically animated or whether the frameAdvance method should be called */ + manualUpdate?: boolean; +} & ( + | { + /** The keyframes for the the animation OR */ + keyframes: Keyframe[]; + source?: never; + } + | { + /** The source data */ + source: SourceData; + keyframes?: never; + } +); + +/** + * A callback function that gets executed for each animation frame. This is simply a utility type. + * @param {AnimationEvent} event - The animation event data provided during animation frame updates. + */ +export type FrameCallback = (event: AnimationEvent) => void; + +export const ANIM_LAYER_PREFIX = "animated-route-layer"; + +/** + * This layer allows you to create animated paths on a map by providing keyframes or a GeoJSON source + * with route data. The animation can control both the visual appearance of the path (using color transitions) + * and optionally animate the camera to follow along the route path. + * @class AnimatedRouteLayer + * + * @example + * ```typescript + * // Create an animated route layer using a GeoJSON source + * const animatedRoute = new AnimatedRouteLayer({ + * source: { + * id: 'route-source', + * layerID: 'route-layer', + * }, + * duration: 5000, + * pathStrokeAnimation: { + * activeColor: [0, 255, 0, 1], + * inactiveColor: [100, 100, 100, 0.5] + * }, + * autoplay: true + * }); + * + * // Add the layer to the map + * map.addLayer(animatedRoute); + * + * // Control playback + * animatedRoute.play(); + * animatedRoute.pause(); + * ``` + * + * @remarks + * The animation can be configured using either explicit keyframes or a GeoJSON source. + * When using a GeoJSON source, the feature can include special properties that control + * animation behavior: + * - `@duration`: Animation duration in milliseconds + * - `@iterations`: Number of times to repeat the animation + * - `@delay`: Delay before starting animation in milliseconds + * - `@autoplay`: Whether to start the animation automatically + * + * Only one AnimatedRouteLayer can be active at a time on a map. + */ +/** + * Creates an animated route layer for MapTiler maps. + * + * The `AnimatedRouteLayer` allows you to animate paths on a map with visual effects and optional camera following. + * You can define animations either through explicit keyframes or by referencing GeoJSON data with animation metadata. + * + * Features: + * - Animate route paths with color transitions (active/inactive segments) + * - Optional camera following along the route + * - Control animation playback (play, pause) + * - Configure animation properties (duration, iterations, delay, easing) + * - Support for manual or automatic animation updates + * - Event system for animation state changes + * + * @example + * ```typescript + * // Create an animated route from GeoJSON source + * const animatedRoute = new AnimatedRouteLayer({ + * source: { + * id: 'route-source', + * layerID: 'route-layer', + * }, + * duration: 5000, + * iterations: 1, + * autoplay: true, + * cameraAnimation: { + * follow: true, + * pathSmoothing: { resolution: 20, epsilon: 5 } + * }, + * pathStrokeAnimation: { + * activeColor: [255, 0, 0, 1], + * inactiveColor: [0, 0, 255, 1] + * } + * }); + * + * // Add the layer to the map + * map.addLayer(animatedRoute); + * + * // Control playback + * animatedRoute.pause(); + * animatedRoute.play(); + * + * // Listen for animation events + * animatedRoute.addEventListener("animationend", () => { + * console.log('Animation completed'); + * }); + * ``` + * + * @implements {CustomLayerInterface} + */ +export class AnimatedRouteLayer implements CustomLayerInterface { + /** Unique ID for the layer */ + readonly id = `${ANIM_LAYER_PREFIX}-${uuidv4()}`; + + readonly type = "custom"; + + /** The MaptilerAnimation instance that handles the animation */ + animationInstance: MaptilerAnimation | null = null; + + /** + * Keyframes for the animation + * If keyframes are provided, they will be used for the animation + * If a source is provided, the keyframes will be parsed from the GeoJSON feature + */ + private keyframes: Keyframe[] | null = null; + + /** + * Source data for the animation + * If a source is provided, it will be used to get the keyframes + * If keyframes are provided, this will be ignored + */ + private source: SourceData | null = null; + + /** The duration of the animation in ms */ + private duration!: number; + + /** The number of interations */ + private iterations!: number; + + /** The delay before the animation starts in ms */ + private delay!: number; + + /** The default easing function for the animation */ + private easing?: EasingFunctionName; + + /** The map instance */ + private map!: MapSDK; + + /** The camera animation options */ + private cameraMaptilerAnimationOptions?: AnimatedCameraOptions; + + /** + * The path stroke animation options + * This controls the color of the path during the animation + */ + private pathStrokeAnimation?: AnimatedStrokeOptions; + + /** Whether the animation will autoplay */ + private autoplay: boolean = false; + + /** Whether the animation will be managed manually */ + private manualUpdate: boolean = false; + + private enquedEventHandlers: AnimationEventListenersRecord = AnimationEventTypesArray.reduce((acc, type) => { + acc[type] = []; + return acc; + }, {} as AnimationEventListenersRecord); + + private enquedCommands: (() => void)[] = []; + + constructor({ + keyframes, + source, + duration, + iterations, + easing, + delay, + cameraAnimation = {}, + pathStrokeAnimation = { + activeColor: [255, 0, 0, 1], + inactiveColor: [0, 0, 255, 1], + } as AnimatedStrokeOptions, + autoplay, + manualUpdate = false, + }: AnimatedRouteLayerOptions) { + this.keyframes = keyframes ?? null; + + this.source = source ?? null; + + if (duration) { + this.duration = duration; + } + + if (iterations) { + this.iterations = iterations; + } + + if (delay) { + this.delay = delay; + } + + this.easing = easing; + + this.cameraMaptilerAnimationOptions = cameraAnimation + ? { + ...{ + pathSmoothing: { + resolution: 20, + epsilon: 5, + }, + follow: true, + }, + ...(cameraAnimation && cameraAnimation), + } + : false; + + if (pathStrokeAnimation) { + this.pathStrokeAnimation = pathStrokeAnimation; + } + + this.autoplay = autoplay ?? false; + + this.manualUpdate = manualUpdate; + + this.update = this.update.bind(this); + } + + /** + * This method is called when the layer is added to the map. + * It initializes the animation instance and sets up event listeners. + * + * @param {MapLibreMap} map - The map instance (maplibre Map, but will be MapSDK at runtime) + * @param {WebGLRenderingContext | WebGL2RenderingContext} _gl - The WebGL context (unused in this layer) + */ + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async onAdd(map: MapSDK): Promise { + this.map = map as MapSDK; + if (this.map.getLayersOrder().some((current) => current.includes(ANIM_LAYER_PREFIX) && this.id !== current)) { + throw new Error(`[AnimatedRouteLayer.onAdd]: Currently, you can only have one active AnimatedRouteLayer at a time. Please remove the existing one before adding a new one.`); + } + + const maptilerAnimationOptions = await this.getMaptilerAnimationOptions(); + + this.animationInstance = new MaptilerAnimation({ + ...maptilerAnimationOptions, + manualMode: this.manualUpdate, + }); + + this.animationInstance.addEventListener("timeupdate", this.update); + + Object.entries(this.enquedEventHandlers).forEach(([type, handlers]) => { + const animationEventKey = type as AnimationEventTypes; + + handlers.forEach((handler) => { + this.animationInstance?.addEventListener(animationEventKey, handler); + }); + + this.enquedEventHandlers[animationEventKey] = []; + }); + + this.enquedCommands.forEach((command) => { + command(); + }); + + this.enquedCommands = []; + + if (this.autoplay) this.animationInstance.play(); + } + + /** + * Initializes the animation instance asynchronously. + * This is called from onAdd but runs asynchronously to handle async data loading. + */ + + /** + * This method is used to manually advance the animation + * + * @returns {AnimatedRouteLayer} - The current instance of AnimatedRouteLayer + */ + frameAdvance() { + if (this.animationInstance && this.manualUpdate) { + this.animationInstance.update(true); + } + return this; + } + + /** + * Adds an event listener to the animation instance. + * + * @param {AnimationEventTypes} type - The type of event to listen for + * @param {FrameCallback} callback - The callback function to execute when the event occurs + */ + addEventListener(type: AnimationEventTypes, callback: FrameCallback): AnimatedRouteLayer { + if (!this.animationInstance) { + this.enquedEventHandlers[type].push(callback); + return this; + } + + this.animationInstance.addEventListener(type, callback); + return this; + } + + /** + * Removes an event listener from the animation instance. + * + * @param {AnimationEventTypes} type - The type of event to remove + * @param {FrameCallback} callback - The callback function to remove + */ + removeEventListener(type: AnimationEventTypes, callback: FrameCallback): AnimatedRouteLayer { + if (!this.animationInstance) { + return this; + } + this.animationInstance.removeEventListener(type, callback); + return this; + } + + public updateManual() { + if (this.animationInstance && this.manualUpdate) { + this.animationInstance.update(true); + } + } + + /** + * Updates the layer's properties based on the animation event. + * @private + * @param {AnimationEvent} event - The animation event + */ + private update(event: AnimationEvent): void { + const { props, currentDelta } = event; + + if (this.source && this.pathStrokeAnimation) { + const { activeColor, inactiveColor } = this.pathStrokeAnimation; + + if (currentDelta >= 1) { + // when the animation is finished, set the color to the active color + this.map.setPaintProperty(this.source.layerID, "line-gradient", ["interpolate", ["linear"], ["line-progress"], 0, ["rgba", ...activeColor], 1, ["rgba", ...activeColor]]); + } else { + this.map.setPaintProperty(this.source.layerID, "line-gradient", [ + "interpolate", + ["linear"], + ["line-progress"], // Progress along the line + 0, + ["rgba", ...activeColor], // color at the start + 0.01 + currentDelta, + ["rgba", ...activeColor], // color at the start + 0.011 + currentDelta, + ["rgba", ...inactiveColor], // color at the transition + 1, + ["rgba", ...inactiveColor], // color at the end + ]); + } + } + + if (props && this.cameraMaptilerAnimationOptions && this.cameraMaptilerAnimationOptions.follow) { + const { lng, lat, bearing, zoom, pitch } = props; + + this.map.jumpTo({ + center: [lng, lat], + pitch: pitch ?? this.map.getPitch(), + zoom: zoom ?? this.map.getZoom(), + bearing: bearing ?? this.map.getBearing(), + }); + } + } + + /** + * Plays the animation. + * + * @returns {AnimatedRouteLayer} - The current instance of AnimatedRouteLayer + */ + play(): AnimatedRouteLayer { + if (!this.animationInstance) { + this.enquedCommands.push(() => { + this.animationInstance?.play(); + }); + return this; + } + this.animationInstance.play(); + return this; + } + + /** + * Stops the animation. + * + * @returns {AnimatedRouteLayer} - The current instance of AnimatedRouteLayer + */ + pause(): AnimatedRouteLayer { + if (!this.animationInstance) { + this.enquedCommands.push(() => { + this.animationInstance?.pause(); + }); + return this; + } + this.animationInstance.pause(); + return this; + } + + /** + * Gets the source GeoJSON data from the map instance, parses it, and returns the animation options. + * + * @returns {Promise} - The MaptilerAnimation constructor options + */ + async getMaptilerAnimationOptions(): Promise { + const map = this.map; + if (this.source) { + const source = map.getSource(this.source.id); + + if (source) { + // this weird type assertion is here to appease typescript + let featureData = (await (source as GeoJSONSource)?.getData()) as GeoJSON.FeatureCollection | GeoJSON.Feature; + + // featureData according to the types should exist, but sometimes does not + if (!featureData) { + throw new Error("[AnimatedRouteLayer.onAdd]: No feature found in source data"); + } + + if (featureData.type === "FeatureCollection") { + console.warn("[AnimatedRouteLayer.onAdd]: FeatureCollection found in source data, only single geojson features are currently supported, first feature will be used"); + featureData = featureData.features[0]; + } + + // better safe than sorry + if (featureData.type !== "Feature") { + throw new Error("[AnimatedRouteLayer.onAdd]: The first feature in the source data is not a valid GeoJSON of type `Feature`"); + } + + const keyframeableFeature = featureData as KeyframeableGeoJSONFeature; + + if (keyframeableFeature.properties["@duration"]) { + this.duration = keyframeableFeature.properties["@duration"] ?? 1000; + } + + if (keyframeableFeature.properties["@iterations"]) { + this.iterations = keyframeableFeature.properties["@iterations"] ?? 0; + } + + if (keyframeableFeature.properties["@delay"]) { + this.delay = keyframeableFeature.properties["@delay"] ?? 0; + } + + if (keyframeableFeature.properties["@autoplay"]) { + this.autoplay = keyframeableFeature.properties["@autoplay"] ?? false; + } + + const keyframes = parseGeoJSONFeatureToKeyframes(keyframeableFeature, { + pathSmoothing: this.cameraMaptilerAnimationOptions ? this.cameraMaptilerAnimationOptions.pathSmoothing : false, + defaultEasing: this.easing, + }); + + const duration = this.duration; + const iterations = this.iterations; + const delay = this.delay; + const autoplay = this.autoplay; + + return { + keyframes, + duration, + iterations, + delay, + autoplay, + }; + } + } + + if (this.keyframes) { + return { + keyframes: this.keyframes, + duration: this.duration, + iterations: this.iterations, + delay: this.delay, + autoplay: this.autoplay, + }; + } + + throw new Error("[AnimatedRouteLayer.onAdd]: No keyframes or source provided"); + } + + /** + * This method is called when the layer is removed from the map. + * It destroys the animation instance. + */ + onRemove(): void { + this.animationInstance?.destroy(); + } + + /** + * This method is called to render the layer. + * It is a no-op for this layer. + */ + render(): void { + return; + } +} diff --git a/src/custom-layers/AnimatedRouteLayer/index.ts b/src/custom-layers/AnimatedRouteLayer/index.ts new file mode 100644 index 00000000..8f509f53 --- /dev/null +++ b/src/custom-layers/AnimatedRouteLayer/index.ts @@ -0,0 +1 @@ +export * from "./AnimatedRouteLayer"; diff --git a/src/index.ts b/src/index.ts index 0cd72f46..1eac5ac9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,6 +180,7 @@ export * from "./ml-types"; // SDK specific export { Map, GeolocationType, type MapOptions, type LoadWithTerrainEvent } from "./Map"; + export * from "./controls"; export { type AutomaticStaticMapOptions, @@ -259,3 +260,6 @@ export * from "./custom-layers/index"; export { ColorRamp, ColorRampCollection } from "./ColorRamp"; export type { RgbaColor, ColorStop, ArrayColor, ArrayColorRampStop, ArrayColorRamp, ColorRampOptions } from "./ColorRamp"; export * from "./utils"; + +export * from "./MaptilerAnimation"; +export * from "./custom-layers/AnimatedRouteLayer"; diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 00000000..8295403b --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,6 @@ +export function arraysAreTheSameLength(...arrays: unknown[][]) { + const length = arrays[0].length; + return arrays.every((array) => { + return array.length === length; + }); +} diff --git a/src/utils/json.ts b/src/utils/json.ts new file mode 100644 index 00000000..7e304f6a --- /dev/null +++ b/src/utils/json.ts @@ -0,0 +1,9 @@ +export function jsonParseNoThrow(doc: string): T | null { + try { + return JSON.parse(doc) as T; + } catch (_e) { + console.error("Error parsing JSON", _e); + } + + return null; +} diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 00000000..97e8a06d --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,5 @@ +export function isUUID(s: string): boolean { + // Regular expression to check if string is a valid UUID + const regexExp = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi; + return regexExp.test(s); +} diff --git a/test-results/tests-map-load-Awaits-until-map-ready-and-takes-snapshot-chromium/trace.zip b/test-results/tests-map-load-Awaits-until-map-ready-and-takes-snapshot-chromium/trace.zip deleted file mode 100644 index 2c4b34a5..00000000 Binary files a/test-results/tests-map-load-Awaits-until-map-ready-and-takes-snapshot-chromium/trace.zip and /dev/null differ diff --git a/test/AnimatedRouteLayer/AnimatedRouteLayer.test.ts b/test/AnimatedRouteLayer/AnimatedRouteLayer.test.ts new file mode 100644 index 00000000..9956e0c7 --- /dev/null +++ b/test/AnimatedRouteLayer/AnimatedRouteLayer.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, vi, test, beforeEach } from "vitest"; +import { Keyframe, MaptilerAnimation } from "../../src/MaptilerAnimation"; +import { AnimatedRouteLayer } from "../../src/custom-layers/AnimatedRouteLayer"; +import { validFixture, validFixtureExpectedKeyframes } from "../fixtures/animations/keyframes.fixture"; + +vi.mock("uuid", () => ({ + v4: () => "PHONEY-UUID", +})); + +const keyframes: Keyframe[] = [ + { + delta: 0, + props: { + opacity: 0, + scale: 1, + }, + }, + { + delta: 0.5, + props: { + opacity: 0.5, + scale: 1.5, + }, + }, + { + delta: 1, + props: { + scale: 2, + opacity: 1, + }, + }, +]; + +const mockSource = { + getData: vi.fn(() => ({ + type: "FeatureCollection", + features: [validFixture], + })), +}; + +class MockMap { + setPaintProperty = vi.fn(); + getPitch = vi.fn(); + getZoom = vi.fn(); + getBearing = vi.fn(); + jumpTo = vi.fn(); + getSource = vi.fn(() => mockSource); + getLayersOrder = vi.fn(); +} + +describe("AnimatedRouteLayer", () => { + const map = new MockMap(); + + vi.spyOn(MaptilerAnimation.prototype, "addEventListener"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("Instantiates correctly with the correct options", () => { + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + expect(layer).toBeInstanceOf(AnimatedRouteLayer); + expect(layer.id).toBe("animated-route-layer-PHONEY-UUID"); + }); + + test("Throws / rejects if the map already has an AnimatedRouteLayer added", async () => { + // fake add a new map + map.getLayersOrder.mockImplementation(() => ["animated-route-layer-PHONEY-UUID-2"]); + + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + const testFn = async () => { + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + }; + + await expect(testFn).rejects.toThrowError( + `[AnimatedRouteLayer.onAdd]: Currently, you can only have one active AnimatedRouteLayer at a time. Please remove the existing one before adding a new one.`, + ); + }); + + test("Initialises the internal animation instance correctly when given keyframes", async () => { + map.getLayersOrder.mockImplementation(() => []); + + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + + expect(layer.animationInstance).toBeInstanceOf(MaptilerAnimation); + + //@ts-expect-error it is private but we can access it here... + layer.animationInstance.keyframes.forEach((keyframe, index) => { + expect(keyframe.delta).toEqual(keyframes[index].delta); + expect(keyframe.props).toEqual(keyframes[index].props); + expect(keyframe.easing).toEqual("Linear"); + expect(keyframe.userData).toEqual(keyframes[index].userData); + }); + + expect(layer.animationInstance?.duration).toEqual(1000); + + //@ts-expect-error it is private but we can access it here... + expect(layer.animationInstance.iterations).toEqual(2); + }); + + test("Initialises the internal animation instance correctly when given a geojson source", async () => { + map.getLayersOrder.mockImplementation(() => []); + + const layer = new AnimatedRouteLayer({ + source: { + id: "test-source", + layerID: "test-layer", + }, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + + expect(layer.animationInstance).toBeInstanceOf(MaptilerAnimation); + + //@ts-expect-error silencing "uninentional this" comment + expect(layer.animationInstance.addEventListener).toHaveBeenCalledWith( + "timeupdate", + //@ts-expect-error silencing "uninentional this" comment + layer.update, + ); + + validFixtureExpectedKeyframes.forEach((keyframe, index) => { + expect(keyframe.delta).toEqual(validFixtureExpectedKeyframes[index].delta); + expect(keyframe.props).toEqual(validFixtureExpectedKeyframes[index].props); + expect(keyframe.easing).toEqual(validFixtureExpectedKeyframes[index].easing); + expect(keyframe.userData).toEqual(validFixtureExpectedKeyframes[index].userData); + }); + + expect(layer.animationInstance?.duration).toEqual(1000); + + //@ts-expect-error it is private but we can access it here... + expect(layer.animationInstance.iterations).toEqual(5); + }); + + test("enqueus calls to addEventListener call to the MapTilerAnimation instance method if the animation instance has not been created yet.", async () => { + map.getLayersOrder.mockImplementation(() => []); + + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + const testFn = vi.fn(); + const testFn2 = vi.fn(); + + layer.addEventListener("animationend", testFn); + layer.addEventListener("iteration", testFn2); + + //@ts-expect-error its not private for tests + expect(layer.enquedEventHandlers).toHaveProperty("animationend", [testFn]); + //@ts-expect-error its not private for tests + expect(layer.enquedEventHandlers).toHaveProperty("iteration", [testFn2]); + + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + + expect(layer.animationInstance?.addEventListener).toHaveBeenCalledWith("animationend", testFn); + + expect(layer.animationInstance?.addEventListener).toHaveBeenCalledWith("iteration", testFn2); + + //@ts-expect-error its not private for tests + expect(layer.enquedEventHandlers.animationend).toEqual([]); + + //@ts-expect-error its not private for tests + expect(layer.enquedEventHandlers.iteration).toEqual([]); + }); + + test("maps addEventListener calls to the animation instance if it has been created", async () => { + map.getLayersOrder.mockImplementation(() => []); + + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + + const testFn = vi.fn(); + const testFn2 = vi.fn(); + + layer.addEventListener("animationend", testFn); + layer.addEventListener("iteration", testFn2); + + expect(layer.animationInstance?.addEventListener).toHaveBeenCalledWith("animationend", testFn); + + expect(layer.animationInstance?.addEventListener).toHaveBeenCalledWith("iteration", testFn2); + }); + + test("enques commands to the animation instance if it has not been created yet.", async () => { + map.getLayersOrder.mockImplementation(() => []); + + const layer = new AnimatedRouteLayer({ + keyframes, + duration: 1000, + iterations: 2, + easing: "Linear", + autoplay: true, + }); + + vi.spyOn(MaptilerAnimation.prototype, "play"); + vi.spyOn(MaptilerAnimation.prototype, "pause"); + + layer.play(); + layer.pause(); + + //@ts-expect-error its not private for tests + expect(layer.enquedCommands).toHaveLength(2); + + //@ts-expect-error map is mocked to avoid webgl explosions... + await layer.onAdd(map); + + expect(layer.animationInstance?.play).toHaveBeenCalled(); + expect(layer.animationInstance?.pause).toHaveBeenCalled(); + + //@ts-expect-error its not private for tests + expect(layer.enquedCommands).toHaveLength(0); + }); +}); diff --git a/test/MaptilerAnimation/MaptilerAnimation.test.ts b/test/MaptilerAnimation/MaptilerAnimation.test.ts new file mode 100644 index 00000000..7e02cad4 --- /dev/null +++ b/test/MaptilerAnimation/MaptilerAnimation.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from "vitest"; +import { MaptilerAnimation } from "../../src/MaptilerAnimation"; + +import { Keyframe } from "../../src/MaptilerAnimation/types"; + +const keyframes: Keyframe[] = [ + { + delta: 0, + props: { x: 0, y: 0 }, + easing: "Linear", + }, + { + delta: 0.5, + props: { x: 50, y: 20 }, + easing: "Linear", + }, + { + delta: 1, + props: { x: 100, y: 0 }, + easing: "Linear", + }, +]; + +const duration = 1000; +const iterations = 2; + +describe("MaptilerAnimation", () => { + it("should initialize with correct properties", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + + expect(animation.getCurrentTime()).toBe(0); + expect(animation.getCurrentDelta()).toBe(0); + expect(animation.getPlaybackRate()).toBe(1); + expect(animation.isPlaying).toBe(false); + }); + + it("should play the animation", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + animation.play(); + expect(animation.isPlaying).toBe(true); + }); + + it("should pause the animation", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + animation.play(); + animation.pause(); + expect(animation.isPlaying).toBe(false); + }); + + it("should stop the animation", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + animation.play(); + animation.stop(); + expect(animation.isPlaying).toBe(false); + }); + + it("should reset the animation", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + expect(animation.getCurrentTime()).toBe(0); + animation.play(); + animation.reset(true); + expect(animation.isPlaying).toBe(false); + expect(animation.getCurrentTime()).toBe(0); + expect(animation.getCurrentDelta()).toBe(0); + expect(animation.isPlaying).toBe(false); + }); + + it("should update animation state", () => { + //@ts-expect-error we only use the now method, so this is fine + vi.spyOn(global, "performance", "get").mockReturnValue({ now: () => 500 }); + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + animation.play(); + + const lastTime = animation.getCurrentTime(); + const lastDelta = animation.getCurrentDelta(); + + //@ts-expect-error we only use the now method, so this is fine + vi.spyOn(global, "performance", "get").mockReturnValue({ now: () => 1000 }); + + animation.update(); + + expect(animation.getCurrentDelta()).toBeGreaterThan(lastDelta); + expect(animation.getCurrentTime()).toBeGreaterThan(lastTime); + }); + + it("should fire events on play, pause, stop", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + const playListener = vi.fn(); + const pauseListener = vi.fn(); + const stopListener = vi.fn(); + + animation.addEventListener("play", playListener); + animation.addEventListener("pause", pauseListener); + animation.addEventListener("stop", stopListener); + + animation.play(); + animation.pause(); + animation.stop(); + + expect(playListener).toHaveBeenCalled(); + expect(pauseListener).toHaveBeenCalled(); + expect(stopListener).toHaveBeenCalled(); + }); + + it("should scrub to a specific time and fire the correct event", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + const scrubListener = vi.fn(); + animation.addEventListener("scrub", scrubListener); + animation.setCurrentTime(500); + expect(animation.getCurrentTime()).toBe(500); + expect(scrubListener).toHaveBeenCalled(); + }); + + it("should scrub to a specific delta", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + const scrubListener = vi.fn(); + animation.addEventListener("scrub", scrubListener); + animation.setCurrentDelta(0.5); + expect(animation.getCurrentDelta()).toBe(0.5); + expect(scrubListener).toHaveBeenCalled(); + }); + + it("should change playback rate", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + animation.setPlaybackRate(2); + expect(animation.getPlaybackRate()).toBe(2); + }); + + it("should clone the animation", () => { + const animation = new MaptilerAnimation({ + keyframes, + duration, + iterations, + }); + const clone = animation.clone(); + expect(clone.getCurrentTime()).toBe(0); + expect(clone.getPlaybackRate()).toBe(1); + expect(clone.getCurrentDelta()).toBe(0); + }); +}); diff --git a/test/MaptilerAnimation/animation-helpers.test.ts b/test/MaptilerAnimation/animation-helpers.test.ts new file mode 100644 index 00000000..d25c8e72 --- /dev/null +++ b/test/MaptilerAnimation/animation-helpers.test.ts @@ -0,0 +1,272 @@ +import { + findNextEntryAndIndexWithValue, + findPreviousEntryAndIndexWithValue, + getAverageDistance, + KeyframeableGeoJSONFeature, + lerp, + lerpArrayValues, + parseGeoJSONFeatureToKeyframes, + simplifyPath, + stretchNumericalArray, +} from "../../src/MaptilerAnimation/animation-helpers"; +import { math } from "@maptiler/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { fixtureOne, invalidGeometryFixture, validFixture, validFixtureExpectedKeyframes } from "../fixtures/animations/keyframes.fixture"; +import validSmoothedKeyframes from "../fixtures/animations/smoothed-keyframes.json"; +import { distancePoints, distancePointsTwo } from "../fixtures/animations/average-distance.fixture"; +import { complexPath, simplifiedAt5Meters, simplifiedPathAt10Meters } from "../fixtures/animations/simplify-path.fixture"; + +describe("[animation-helpers]: lerp", () => { + test("interpolates correctly", () => { + const fixtures = [ + { + a: 0, + b: 10, + alpha: 0, + expected: 0, + }, + { + a: 0, + b: 10, + alpha: 0.5, + expected: 5, + }, + { + a: 0, + b: 10, + alpha: 1, + expected: 10, + }, + { + a: -10, + b: 10, + alpha: 0.5, + expected: 0, + }, + { + a: -10, + b: -20, + alpha: 0.5, + expected: -15, + }, + ]; + + fixtures.forEach(({ a, b, alpha, expected }) => { + expect(lerp(a, b, alpha)).toBe(expected); + }); + }); +}); + +describe("[animation-helpers]: lerpArrayValues", () => { + test("throws when an empty array is passed", () => { + const testFn = () => { + lerpArrayValues([]); + }; + + expect(testFn).toThrowError("[lerpArrayValues]: Array empty, nothing to interpolate"); + }); + + test("throws when all values in the array are null", () => { + const testFn = () => { + lerpArrayValues([null, null, null]); + }; + + expect(testFn).toThrowError("[lerpArrayValues]: Cannot interpolate an array where all values are `null`"); + }); + + test("interpolates null values between numerical values correctly", () => { + const arr1 = [0, null, null, 100, null, 200]; + const expected = [0, 33.333333333, 66.666666666, 100, 150, 200]; + const result = lerpArrayValues(arr1); + result.forEach((received, index) => { + expect(received).toBeCloseTo(expected[index], 8); + }); + }); + + test("interpolates null values between numerical values correctly and `fills` null values at either end of the array", () => { + const arr2 = [null, 0, null, 100, null, null, null]; + expect(lerpArrayValues(arr2)).toEqual([0, 0, 50, 100, 100, 100, 100]); + + const arr3 = [0, 1, null, 3, null, 4, null, null]; + expect(lerpArrayValues(arr3)).toEqual([0, 1, 2, 3, 3.5, 4, 4, 4]); + }); +}); + +describe("[animation-helpers]: findNextEntryAndIndexWithValue", () => { + test("returns the next entry and index with a value", () => { + const [nextIndex1, nextEntry1] = findNextEntryAndIndexWithValue([null, null, null, null, 5, null, 7, null], 1); + expect(nextIndex1).toBe(4); + expect(nextEntry1).toBe(5); + + const [nextIndex2, nextEntry2] = findNextEntryAndIndexWithValue([null, null, null, null, 5, null, 7, null], 3); + expect(nextIndex2).toBe(4); + expect(nextEntry2).toBe(5); + + const [nextIndex3, nextEntry3] = findNextEntryAndIndexWithValue([null, null, null, null, 5, null, 7, null], 5); + expect(nextIndex3).toBe(6); + expect(nextEntry3).toBe(7); + }); + + test("returns [null, null] if not found", () => { + const [nextIndex1, nextEntry1] = findNextEntryAndIndexWithValue([5, null, null, null], 1); + expect(nextIndex1).toBe(null); + expect(nextEntry1).toBe(null); + }); +}); + +describe("[animation-helpers]: findPreviousEntryAndIndexWithValue", () => { + test("returns the next entry and index with a value", () => { + const [nextIndex1, nextEntry1] = findPreviousEntryAndIndexWithValue([null, 3, null, null, 5, null, 7, null], 2); + expect(nextIndex1).toBe(1); + expect(nextEntry1).toBe(3); + + const [nextIndex2, nextEntry2] = findPreviousEntryAndIndexWithValue([null, 3, null, null, 5, null, 7, null], 5); + expect(nextIndex2).toBe(4); + expect(nextEntry2).toBe(5); + + const [nextIndex3, nextEntry3] = findPreviousEntryAndIndexWithValue([null, 3, null, null, 5, null, 7, null], 8); + expect(nextIndex3).toBe(6); + expect(nextEntry3).toBe(7); + }); +}); + +describe("[animation-helpers]: parseGeoJSONFeatureToKeyframes", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + afterEach(() => { + consoleSpy.mockClear(); + }); + + test("warns when no @easing or @delta props are provided in the geoJSON is provided", () => { + parseGeoJSONFeatureToKeyframes(fixtureOne); + expect(consoleSpy).toHaveBeenCalledWith("[parseGeoJSONFeatureToKeyframes]: No '@easing' property found in GeoJSON properties, using default easing Linear"); + expect(consoleSpy).toHaveBeenCalledWith( + "[parseGeoJSONFeatureToKeyframes]: No '@delta' property found in GeoJSON properties, delta for each frame will default to its index divided by the total", + ); + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + test("throws when the geometry is invalid", () => { + const testFn = () => { + parseGeoJSONFeatureToKeyframes(invalidGeometryFixture); + }; + + expect(testFn).toThrowError("[parseGeoJSONFeatureToKeyframes]: No geometry found in feature"); + }); + + test("throws when the an unsupported geometry type is passed", () => { + const testFn = () => { + // @ts-expect-error this is an invalid geometry type to invoke an error + parseGeoJSONFeatureToKeyframes({ + ...invalidGeometryFixture, + geometry: { + ...invalidGeometryFixture.geometry, + type: "UnsupportedGeometryType", + }, + } as KeyframeableGeoJSONFeature); + }; + + expect(testFn).toThrowError( + "[parseGeoJSONFeatureToKeyframes]: Geometry type 'UnsupportedGeometryType' is not supported. Accepted types are: MultiPoint, LineString, MultiLineString, Polygon", + ); + }); + + test("parses the fixture to the expected keyframes (no-smoothing)", () => { + const receviedKeyframes = parseGeoJSONFeatureToKeyframes(validFixture, { pathSmoothing: false }); + expect(receviedKeyframes).toEqual(validFixtureExpectedKeyframes); + }); + + test("parses the fixture to the expected keyframes (with smoothing)", () => { + const receviedKeyframes = parseGeoJSONFeatureToKeyframes(validFixture, { + pathSmoothing: { + resolution: 10, + epsilon: 0.5, + }, + }); + expect(receviedKeyframes).toEqual(validSmoothedKeyframes); + }); +}); + +describe("[animation-helpers]: stretchNumericalArray", () => { + test("stretches an array of numbers to a given length, inserting null in the new indices, placing the old values in the closest appropriate index", () => { + const stretched1 = stretchNumericalArray([0, 1, 2], 10); + expect(stretched1).toEqual([0, null, null, null, null, 1, null, null, null, 2]); + + const stretched2 = stretchNumericalArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 50); + expect(stretched2).toEqual([ + 0, + null, + null, + null, + null, + 1, + null, + null, + null, + null, + 2, + null, + null, + null, + null, + 3, + null, + null, + null, + null, + 4, + null, + null, + null, + null, + 5, + null, + null, + null, + 6, + null, + null, + null, + null, + 7, + null, + null, + null, + null, + 8, + null, + null, + null, + null, + 9, + null, + null, + null, + null, + 10, + ]); + }); +}); + +describe("[animation-helpers]: getAverageDistance", () => { + test("given an array of lnglat points, it calculates the correct average distance (haversine) in meters between these points to within 10m", () => { + const avg = getAverageDistance(distancePoints); + expect(avg).toBeCloseTo(40.7978395); + + const avgAtEquator = getAverageDistance(distancePointsTwo); + + expect(avgAtEquator).toBeCloseTo(math.EARTH_CIRCUMFERENCE / distancePointsTwo.length, -1); // expect the answer to be within 10m + }); +}); + +describe("[animation-helpers]: simplifyPath", () => { + test(() => { + const received = simplifyPath(complexPath, 10); + expect(received).toEqual(simplifiedPathAt10Meters); + expect(received).not.toEqual(complexPath); + + const received2 = simplifyPath(complexPath, 5); + expect(received2).toEqual(simplifiedAt5Meters); + expect(received2).not.toEqual(complexPath); + }); +}); diff --git a/test/MaptilerAnimation/easing-function.test.ts b/test/MaptilerAnimation/easing-function.test.ts new file mode 100644 index 00000000..4c9af645 --- /dev/null +++ b/test/MaptilerAnimation/easing-function.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import EasingFunctions from "../../src/MaptilerAnimation/easing"; + +import easingsDictionary from "../fixtures/animations/easings.json"; + +describe("Easing Function:", () => { + test("Each easing function returns the expected values", () => { + Object.entries(EasingFunctions).forEach(([key, fn]) => { + const expected = easingsDictionary[key as keyof typeof easingsDictionary]; + Array.from({ length: 21 }, (_, i) => fn(i / 20)).forEach( + (value, index) => { + expect(value).toBeCloseTo(expected[index], 8); + }, + ); + }); + }); +}); diff --git a/test/__snapshots__/exports.test.ts.snap b/test/__snapshots__/exports.test.ts.snap index 1894821c..19d293d8 100644 --- a/test/__snapshots__/exports.test.ts.snap +++ b/test/__snapshots__/exports.test.ts.snap @@ -3,6 +3,8 @@ exports[`Module Exports > should match the snapshot of module exports 1`] = ` [ "AJAXError", + "ANIM_LAYER_PREFIX", + "AnimatedRouteLayer", "AttributionControl", "AttributionControlMLGL", "BoxZoomHandler", @@ -54,6 +56,7 @@ exports[`Module Exports > should match the snapshot of module exports 1`] = ` "MapTouchEventMLGL", "MapWheelEvent", "MapWheelEventMLGL", + "MaptilerAnimation", "MaptilerCustomControl", "MaptilerExternalControl", "MaptilerGeolocateControl", @@ -104,6 +107,7 @@ exports[`Module Exports > should match the snapshot of module exports 1`] = ` "config", "configMLGL", "coordinates", + "createBezierPathFromCoordinates", "cubemapPresets", "data", "displayWebGLContextLostWarning", @@ -112,6 +116,7 @@ exports[`Module Exports > should match the snapshot of module exports 1`] = ` "geocoding", "geolocation", "getAutoLanguage", + "getAverageDistance", "getBrowserLanguage", "getBufferToPixelDataParser", "getLanguageInfoFromCode", @@ -132,17 +137,23 @@ exports[`Module Exports > should match the snapshot of module exports 1`] = ` "importScriptInWorkers", "isLanguageInfo", "kml", + "lerp", + "lerpArrayValues", "mapStylePresetList", "math", "misc", + "parseGeoJSONFeatureToKeyframes", "prewarm", "removeProtocol", + "resamplePath", "setMaxParallelImageRequests", "setRTLTextPlugin", "setWorkerCount", "setWorkerUrl", + "simplifyPath", "staticMaps", "str2xml", + "stretchNumericalArray", "styleToStyle", "toLanguageInfo", "toggleProjection", diff --git a/test/exports.test.ts b/test/exports.test.ts index 932dce27..7a2403dc 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -143,28 +143,28 @@ const expectedExports = Array.from( ...Object.keys(converters), ...Object.keys(ColorRamp), ...Object.keys(helpers), + + // MaptilerAnimation & AnimatedRouteLayerModule + "AnimatedRouteLayer", + "MaptilerAnimation", + "ANIM_LAYER_PREFIX", + "lerp", + "lerpArrayValues", + "parseGeoJSONFeatureToKeyframes", + "createBezierPathFromCoordinates", + "getAverageDistance", + "simplifyPath", + "resamplePath", + "createBezierPathFromCoordinates", + "getAverageDistance", + "stretchNumericalArray", + "simplifyPath", ]), ); describe("Module Exports", async () => { const exportedModule = await import("../src/index"); - console.info( - "\x1b[1m%s\x1b[0m", - ` - At present you will likely see an error above: - - \`Error: Failed to load url (resolved id: ). Does the file exist?\` - - This is related to maplibre-gl and the way it handles CJS modules. - It is caused by the call to \`enableRTL()\` in src/index.ts. - - There is a planned future work to remove this (the API it relies on - is deprecated in Maplibre). But, for now, we must tolerate some - noise in this test... - `, - ); - it("should match number of exptected exports with expected number of exports, logging any superfluous exports", () => { const actualExports = Object.keys(exportedModule); const superfluousExports = actualExports.filter((key) => !expectedExports.includes(key)); diff --git a/test/fixtures/animations/average-distance.fixture.ts b/test/fixtures/animations/average-distance.fixture.ts new file mode 100644 index 00000000..8bdab5ac --- /dev/null +++ b/test/fixtures/animations/average-distance.fixture.ts @@ -0,0 +1,194 @@ +// avg distance is 40.7978395 from online geojson calc +export const distancePoints: [number, number][] = [ + [-7.453472539769251, 39.415519175998355], + [-7.45325336192937, 39.4156388735492], + [-7.453531113157567, 39.41581404032513], + [-7.453788080279594, 39.41604175647706], + [-7.453922232234078, 39.4161687516999], + [-7.454007258120328, 39.416221301379295], + [-7.454099841862472, 39.416221301379295], + [-7.454256667386403, 39.41615269484561], + [-7.45438326148323, 39.41629428698019], + [-7.45458921307457, 39.41647529094024], + [-7.454659123247268, 39.416576010681865], + [-7.454685575745117, 39.41663147946278], + [-7.454672349496633, 39.41666505265138], + [-7.454634560213577, 39.41668402879404], + [-7.454536308077934, 39.41669862582319], + [-7.454436166479013, 39.41673073927731], + [-7.4543001250612235, 39.41675701390977], + [-7.454139251708426, 39.41679288839924], + [-7.4541169768876046, 39.416831129183436], + [-7.454022927641404, 39.41684068937607], + [-7.453847204051328, 39.41685024956789], + [-7.453745729865574, 39.41685024956789], + [-7.453718505084282, 39.41686936994665], + [-7.45371108014362, 39.416907610689265], + [-7.453659222148588, 39.416917485473704], + [-7.453665223396598, 39.416982392814276], + [-7.453737238370309, 39.41699398340427], + [-7.4536952296354, 39.417061208789335], + [-7.453656221524511, 39.417102934858036], + [-7.453659222148588, 39.41715393335227], + [-7.454452481972908, 39.41719879152794], + [-7.4556627068376145, 39.417574698550965], + [-7.455824901922853, 39.41765502030117], + [-7.456024526643432, 39.41797951923206], + [-7.455915536646046, 39.418009529115835], + [-7.456252667211032, 39.41869583306371], + [-7.456503237226002, 39.41893867745799], + [-7.456676358327115, 39.41920967672442], + [-7.456676358327115, 39.419427883161006], + [-7.456717360693318, 39.41967424444505], + [-7.456872258520065, 39.420138809069385], + [-7.456815186161634, 39.42029664150152], + [-7.456959227598048, 39.42076272793693], + [-7.4570407604869615, 39.42104825682648], + [-7.457206544027741, 39.42120361764262], + [-7.4573560209915115, 39.42125610432626], + [-7.457470486396545, 39.42138174472842], + [-7.458314966363702, 39.421880895611764], + [-7.458410929996376, 39.422179890220576], + [-7.458426923934923, 39.422281202152874], + [-7.45831176757585, 39.42238251393735], + [-7.458388538482154, 39.42255548493705], + [-7.458494098478383, 39.422599963124384], + [-7.45864764029028, 39.422585137065084], + [-7.458695622106632, 39.422639499267376], + [-7.4586316463517335, 39.422827295640246], + [-7.458850762438146, 39.422921396416655], + [-7.45920297852274, 39.42296976613008], + [-7.459304729836731, 39.42301511270648], + [-7.4593634325176765, 39.4236136847492], + [-7.459766524259692, 39.4236136847492], + [-7.45997549214124, 39.42376808151175], + [-7.460162959559625, 39.4238364653921], + [-7.460402501261342, 39.42404563919828], + [-7.460402501261342, 39.42432721833063], + [-7.460631628106171, 39.42456857096724], + [-7.460985733230331, 39.42499495858232], + [-7.461162785791771, 39.42510758883736], + [-7.461292971499546, 39.42547765839436], + [-7.461595002340374, 39.425537995635835], + [-7.461742045047515, 39.42531890662306], + [-7.461907232673752, 39.42522650679359], + [-7.463158244162145, 39.4252026823261], + [-7.464713077858249, 39.42464219568453], + [-7.465438666916157, 39.42433335417971], + [-7.466149448034429, 39.423738544463674], + [-7.46665291799269, 39.42317804604639], + [-7.466667725933149, 39.42254890980095], + [-7.46663811005314, 39.42114191182756], + [-7.466771381512729, 39.420501317975436], + [-7.46715638795186, 39.420467000281036], + [-7.4675117785109535, 39.42051275720269], + [-7.4681781358088415, 39.420295411557646], + [-7.468651989887974, 39.42044412180894], + [-7.468933340747554, 39.42011238312082], + [-7.469392386886284, 39.419792082199365], + [-7.469881048904995, 39.41958617368718], + [-7.470280863283676, 39.41911715758329], + [-7.469747777445406, 39.41876253355514], + [-7.468000440529693, 39.41816767629666], + [-7.468459486668394, 39.417904564735636], + [-7.469644121865855, 39.41797320262998], + [-7.470917604702407, 39.41815623668384], + [-7.471909736679407, 39.41848798468172], + [-7.473434954495559, 39.41900276293251], + [-7.4738643847542505, 39.41901420240572], + [-7.474486318233431, 39.41901420240572], + [-7.475063827891262, 39.41922011260817], + [-7.475345178750814, 39.41916291539039], + [-7.475359986690421, 39.41896844450099], + [-7.4748417087917005, 39.41876253355514], + [-7.474708437332083, 39.41865957785441], + [-7.474856516732103, 39.41848798468172], + [-7.475345178750814, 39.41867101738458], + [-7.476026324721147, 39.41899133597087], + [-7.476781529658979, 39.41905997279508], + [-7.4773738472581215, 39.418831183118755], + [-7.4776551981168495, 39.41825920564219], + [-7.47761077429729, 39.41776730125912], + [-7.477107304338148, 39.41752706762398], + [-7.476529794680289, 39.41733259217017], + [-7.475493238882848, 39.41705803766487], + [-7.474900921283705, 39.41687500072743], + [-7.474517799683866, 39.416176041313406], + [-7.473882333590893, 39.41556609271535], + [-7.47324686749792, 39.414732983569564], + [-7.472746197242628, 39.41430154813483], + [-7.472187757342937, 39.41419740814641], + [-7.471128647187953, 39.414108145176584], + [-7.470396898352789, 39.41384035557883], + [-7.469838458453069, 39.41331965064083], + [-7.469434070938888, 39.412769186905365], + [-7.468933400684676, 39.41250139216473], + [-7.4679705732707475, 39.412322861767024], + [-7.467200311340065, 39.41204018770151], + [-7.46654558869821, 39.411489713864455], + [-7.465794583315301, 39.410790457048535], + [-7.465582761284281, 39.41010607124639], + [-7.465409452349917, 39.4095704602812], + [-7.4647932428058255, 39.40901996694589], + [-7.463734132650842, 39.408409955741405], + [-7.4628098183335965, 39.40848434763711], + [-7.462155095691685, 39.408573617807775], + [-7.461250037923293, 39.408216536440364], + [-7.460826393861282, 39.40830580695385], + [-7.46053754563701, 39.408335563766286], + [-7.460595315281495, 39.40875215780622], + [-7.460526750518596, 39.408810938181375], + [-7.460538494150569, 39.408901673642674], + [-7.460456288729574, 39.40896065162889], + [-7.460303621518165, 39.40899694574969], + [-7.460356467860635, 39.40911036475495], + [-7.459998287096738, 39.40900601927663], + [-7.4595285418332935, 39.409155732305294], + [-7.45898833478077, 39.409219246826865], + [-7.45882392393878, 39.40924193057006], + [-7.458753462148337, 39.409319055243145], + [-7.459193848333285, 39.40976819134863], + [-7.459193848333285, 39.40984531543913], + [-7.458894385727831, 39.40990882933207], + [-7.458712359437868, 39.40997234316711], + [-7.458683000359343, 39.410044930336596], + [-7.458747590332393, 39.41012205412102], + [-7.458418768648386, 39.410230934613196], + [-7.458254357806396, 39.410557575068566], + [-7.458013613358048, 39.410807091052334], + [-7.457813971621476, 39.41106114277309], + [-7.457532124462631, 39.41128797388461], + [-7.457608458068989, 39.411410462377546], + [-7.45750863720005, 39.41160099960618], + [-7.457440569868993, 39.41178964110193], + [-7.457596686546822, 39.4118785161441], + [-7.457161203182466, 39.41249429011131], + [-7.456914703165921, 39.41275456406984], + [-7.456873619829366, 39.41298944461357], + [-7.456840753160321, 39.413167191526526], + [-7.4562409364515645, 39.41309736243639], + [-7.4562409364515645, 39.41327510907348], + [-7.456035519770694, 39.41351633592819], + [-7.455920486429449, 39.41358616459871], + [-7.455419270830589, 39.41363695090493], + [-7.455131687477518, 39.41360521063166], + [-7.4553781874950005, 39.41390991665597], + [-7.4553864041615725, 39.414017833053975], + [-7.454983787467171, 39.41377660793444], + [-7.454794804120354, 39.41374486772477], + [-7.4544004040925245, 39.413922612711815], + [-7.454121037405997, 39.41407496519642], + [-7.453882754056025, 39.41429079731378], + [-7.4538005873838244, 39.41436062520859], + [-7.454318237420381, 39.414785939057936], + [-7.454597604106908, 39.415014464830534], + [-7.454326454087862, 39.415185858667996], + [-7.453808804051306, 39.41536994787708], + [-7.453545870698861, 39.415490557783954], + [-7.453472539769251, 39.415519175998355], + [-7.453472539769251, 39.415519175998355], +]; + +export const distancePointsTwo: [number, number][] = Array.from({ length: 360 * 10 }, (_, i) => { + return [i / 10, 0]; +}); diff --git a/test/fixtures/animations/easings.json b/test/fixtures/animations/easings.json new file mode 100644 index 00000000..c18a251f --- /dev/null +++ b/test/fixtures/animations/easings.json @@ -0,0 +1,81 @@ +{ + "Linear": [0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1], + "QuadraticIn": [ + 0, 0.0025000000000000005, 0.010000000000000002, 0.0225, 0.04000000000000001, 0.0625, 0.09, 0.12249999999999998, 0.16000000000000003, 0.2025, 0.25, 0.30250000000000005, 0.36, + 0.42250000000000004, 0.48999999999999994, 0.5625, 0.6400000000000001, 0.7224999999999999, 0.81, 0.9025, 1 + ], + "QuadraticOut": [ + 0, 0.0975, 0.19, 0.2775, 0.36000000000000004, 0.4375, 0.51, 0.5774999999999999, 0.6400000000000001, 0.6975, 0.75, 0.7975, 0.84, 0.8775000000000001, 0.9099999999999999, 0.9375, + 0.96, 0.9774999999999999, 0.9900000000000001, 0.9974999999999999, 1 + ], + "QuadraticInOut": [ + 0, 0.005000000000000001, 0.020000000000000004, 0.045, 0.08000000000000002, 0.125, 0.18, 0.24499999999999997, 0.32000000000000006, 0.405, 0.5, 0.5950000000000001, + 0.6799999999999999, 0.755, 0.82, 0.875, 0.92, 0.955, 0.98, 0.995, 1 + ], + "CubicIn": [ + 0, 0.00012500000000000003, 0.0010000000000000002, 0.003375, 0.008000000000000002, 0.015625, 0.027, 0.04287499999999999, 0.06400000000000002, 0.09112500000000001, 0.125, + 0.16637500000000005, 0.216, 0.27462500000000006, 0.3429999999999999, 0.421875, 0.5120000000000001, 0.6141249999999999, 0.7290000000000001, 0.8573749999999999, 1 + ], + "CubicOut": [ + 0, 0.1426250000000001, 0.2709999999999999, 0.3858750000000001, 0.4879999999999999, 0.578125, 0.657, 0.7253749999999999, 0.784, 0.833625, 0.875, 0.908875, 0.9359999999999999, + 0.957125, 0.973, 0.984375, 0.992, 0.996625, 0.999, 0.999875, 1 + ], + "CubicInOut": [ + 0, 0.0005000000000000001, 0.004000000000000001, 0.0135, 0.03200000000000001, 0.0625, 0.108, 0.17149999999999996, 0.25600000000000006, 0.36450000000000005, 0.5, + 0.6355000000000002, 0.744, 0.8285, 0.8919999999999999, 0.9375, 0.968, 0.9865, 0.996, 0.9994999999999999, 1 + ], + "SinusoidalIn": [ + 0, 0.003082666266872036, 0.01231165940486223, 0.027630079602323443, 0.04894348370484647, 0.07612046748871326, 0.1089934758116321, 0.1473598356459077, 0.19098300562505255, + 0.23959403439996907, 0.2928932188134524, 0.35055195166981645, 0.41221474770752686, 0.4775014352840511, 0.5460095002604531, 0.6173165676349102, 0.6909830056250525, + 0.7665546361440945, 0.843565534959769, 0.921540904272155, 0.9999999999999999 + ], + "SinusoidalOut": [ + 0, 0.07845909572784494, 0.15643446504023087, 0.2334453638559054, 0.3090169943749474, 0.3826834323650898, 0.45399049973954675, 0.5224985647159488, 0.5877852522924731, + 0.6494480483301837, 0.7071067811865475, 0.760405965600031, 0.8090169943749475, 0.8526401643540922, 0.8910065241883678, 0.9238795325112867, 0.9510565162951535, + 0.9723699203976766, 0.9876883405951378, 0.996917333733128, 1 + ], + "SinusoidalInOut": [ + 0, 0.006155829702431115, 0.024471741852423234, 0.05449673790581605, 0.09549150281252627, 0.1464466094067262, 0.20610737385376343, 0.27300475013022657, 0.3454915028125263, + 0.4217827674798845, 0.49999999999999994, 0.5782172325201155, 0.6545084971874737, 0.7269952498697734, 0.7938926261462365, 0.8535533905932737, 0.9045084971874737, + 0.9455032620941839, 0.9755282581475768, 0.9938441702975689, 1 + ], + "ExponentialIn": [ + 0, 0.0013810679320049762, 0.0019531249999999998, 0.002762135864009952, 0.0039062499999999987, 0.005524271728019903, 0.007812500000000002, 0.011048543456039804, + 0.015625000000000003, 0.022097086912079605, 0.03125, 0.04419417382415924, 0.06249999999999999, 0.08838834764831846, 0.12499999999999996, 0.1767766952966369, + 0.25000000000000006, 0.35355339059327373, 0.5000000000000001, 0.7071067811865474, 1 + ], + "ExponentialOut": [ + 0, 0.29289321881345254, 0.5, 0.6464466094067263, 0.75, 0.8232233047033631, 0.875, 0.9116116523516815, 0.9375, 0.9558058261758408, 0.96875, 0.9779029130879204, 0.984375, + 0.9889514565439602, 0.9921875, 0.99447572827198, 0.99609375, 0.99723786413599, 0.998046875, 0.998618932067995, 1 + ], + "ExponentialInOut": [ + 0, 0.0009765624999999999, 0.0019531249999999993, 0.003906250000000001, 0.007812500000000002, 0.015625, 0.031249999999999997, 0.06249999999999998, 0.12500000000000003, + 0.25000000000000006, 0.5, 0.7500000000000002, 0.875, 0.9375, 0.96875, 0.984375, 0.9921875, 0.99609375, 0.998046875, 0.9990234375, 1 + ], + "ElasticIn": [ + 0, -0.000976562499999997, 1.1959441397923372e-18, 0.001953125000000003, 0.00390625, 0.0039062499999999952, -3.827021247335479e-18, -0.00781250000000001, -0.015625, + -0.015624999999999981, 1.1481063742006436e-17, 0.03125000000000004, 0.0625, 0.06249999999999988, -3.061616997868382e-17, -0.12500000000000003, -0.25000000000000006, + -0.24999999999999994, 6.123233995736767e-17, 0.49999999999999944, 1 + ], + "ElasticOut": [ + 0, 0.5000000000000001, 1, 1.25, 1.25, 1.125, 1, 0.9375000000000001, 0.9375, 0.96875, 1, 1.015625, 1.015625, 1.0078125, 1, 0.99609375, 0.99609375, 0.998046875, 1, 1.0009765625, + 1 + ], + "ElasticInOut": [ + 0, 5.979720698961686e-19, 0.001953125, -1.9135106236677394e-18, -0.0078125, 5.740531871003218e-18, 0.03125, -1.530808498934191e-17, -0.12500000000000003, + 3.0616169978683836e-17, 0.5, 1.0000000000000002, 1.125, 1, 0.96875, 1, 1.0078125, 1, 0.998046875, 1, 1 + ], + "BounceIn": [ + 0, 0.015468750000000031, 0.01187500000000008, 0.05484375000000008, 0.06000000000000005, 0.02734375, 0.06937499999999996, 0.1673437499999999, 0.22750000000000004, + 0.24984375000000003, 0.234375, 0.18109375000000005, 0.09000000000000019, 0.07359375000000024, 0.31937499999999985, 0.52734375, 0.6975000000000001, 0.82984375, + 0.9243750000000001, 0.98109375, 1 + ], + "BounceOut": [ + 0, 0.018906250000000003, 0.07562500000000001, 0.17015624999999998, 0.30250000000000005, 0.47265625, 0.6806249999999999, 0.9264062499999998, 0.9099999999999998, 0.81890625, + 0.765625, 0.75015625, 0.7725, 0.8326562500000001, 0.930625, 0.97265625, 0.94, 0.9451562499999999, 0.9881249999999999, 0.98453125, 1 + ], + "BounceInOut": [ + 0, 0.00593750000000004, 0.030000000000000027, 0.03468749999999998, 0.11375000000000002, 0.1171875, 0.045000000000000095, 0.15968749999999993, 0.34875000000000006, + 0.46218750000000003, 0.5, 0.5378125, 0.6512499999999999, 0.8403125, 0.9550000000000001, 0.8828125, 0.88625, 0.9653125, 0.97, 0.9940624999999998, 1 + ] +} diff --git a/test/fixtures/animations/keyframes.fixture.ts b/test/fixtures/animations/keyframes.fixture.ts new file mode 100644 index 00000000..bbf0cdd0 --- /dev/null +++ b/test/fixtures/animations/keyframes.fixture.ts @@ -0,0 +1,172 @@ +import { Keyframe, KeyframeableGeoJSONFeature } from "../../../src/MaptilerAnimation"; + +export const fixtureOne: KeyframeableGeoJSONFeature = { + type: "Feature", + properties: {}, + geometry: { + coordinates: [], + type: "LineString", + }, +}; + +export const invalidGeometryFixture: KeyframeableGeoJSONFeature = { + type: "Feature", + properties: {}, + // @ts-expect-error this is a duff type to invoke an error + geometry: { + coordinates: [], + }, +}; + +export const validFixture: KeyframeableGeoJSONFeature = { + type: "Feature", + properties: { + "@easing": ["Linear", "BounceIn", "Linear", "Linear", "Linear", "Linear", "Linear", "Linear", "Linear", "Linear", "ElasticIn"], + "@delta": [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1], + "@autoplay": true, + "@duration": 1000, + "@iterations": 5, + propertyOne: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + propertyTwo: [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0], + }, + geometry: { + coordinates: [ + [0, 0], + [1, 1, 10], + [2, 2], + [3, 3, 50], + [4, 4, 40], + [5, 5], + [6, 6, 60], + [7, 7, Math.PI], + [8, 8, 1000], + [9, 9], + [10, 10, 0], + ], + type: "LineString", + }, +}; + +export const validFixtureExpectedKeyframes: Keyframe[] = [ + { + easing: "Linear", + delta: 0, + props: { + propertyOne: 0, + propertyTwo: 100, + lat: 0, + lng: 0, + altitude: null, + }, + }, + { + easing: "BounceIn", + delta: 0.1, + props: { + propertyOne: 10, + propertyTwo: 90, + lat: 1, + lng: 1, + altitude: 10, + }, + }, + { + easing: "Linear", + delta: 0.2, + props: { + propertyOne: 20, + propertyTwo: 80, + lat: 2, + lng: 2, + altitude: null, + }, + }, + { + easing: "Linear", + delta: 0.3, + props: { + propertyOne: 30, + propertyTwo: 70, + lat: 3, + lng: 3, + altitude: 50, + }, + }, + { + easing: "Linear", + delta: 0.4, + props: { + propertyOne: 40, + propertyTwo: 60, + lat: 4, + lng: 4, + altitude: 40, + }, + }, + { + easing: "Linear", + delta: 0.5, + props: { + propertyOne: 50, + propertyTwo: 50, + lat: 5, + lng: 5, + altitude: null, + }, + }, + { + easing: "Linear", + delta: 0.6, + props: { + propertyOne: 60, + propertyTwo: 40, + lat: 6, + lng: 6, + altitude: 60, + }, + }, + { + easing: "Linear", + delta: 0.7, + props: { + propertyOne: 70, + propertyTwo: 30, + lat: 7, + lng: 7, + altitude: Math.PI, + }, + }, + { + easing: "Linear", + delta: 0.8, + props: { + propertyOne: 80, + propertyTwo: 20, + lat: 8, + lng: 8, + altitude: 1000, + }, + }, + { + easing: "Linear", + delta: 0.9, + props: { + propertyOne: 90, + propertyTwo: 10, + lat: 9, + lng: 9, + altitude: null, + }, + }, + { + easing: "ElasticIn", + delta: 1, + props: { + propertyOne: 100, + propertyTwo: 0, + lat: 10, + lng: 10, + altitude: 0, + }, + }, +]; diff --git a/test/fixtures/animations/simplify-path.fixture.ts b/test/fixtures/animations/simplify-path.fixture.ts new file mode 100644 index 00000000..390ca147 --- /dev/null +++ b/test/fixtures/animations/simplify-path.fixture.ts @@ -0,0 +1,58 @@ +export const complexPath: [number, number][] = [ + [-7.461250037923293, 39.408216536440364], + [-7.460826393861282, 39.40830580695385], + [-7.46053754563701, 39.408335563766286], + [-7.460595315281495, 39.40875215780622], + [-7.460526750518596, 39.408810938181375], + [-7.460538494150569, 39.408901673642674], + [-7.460456288729574, 39.40896065162889], + [-7.460303621518165, 39.40899694574969], + [-7.460356467860635, 39.40911036475495], + [-7.459998287096738, 39.40900601927663], + [-7.4595285418332935, 39.409155732305294], + [-7.45898833478077, 39.409219246826865], + [-7.45882392393878, 39.40924193057006], + [-7.458753462148337, 39.409319055243145], + [-7.459193848333285, 39.40976819134863], + [-7.459193848333285, 39.40984531543913], + [-7.458894385727831, 39.40990882933207], + [-7.458712359437868, 39.40997234316711], + [-7.458683000359343, 39.410044930336596], + [-7.458747590332393, 39.41012205412102], + [-7.458418768648386, 39.410230934613196], + [-7.458254357806396, 39.410557575068566], + [-7.458013613358048, 39.410807091052334], + [-7.457813971621476, 39.41106114277309], + [-7.457532124462631, 39.41128797388461], + [-7.457608458068989, 39.411410462377546], + [-7.45750863720005, 39.41160099960618], + [-7.457440569868993, 39.41178964110193], + [-7.457596686546822, 39.4118785161441], + [-7.457161203182466, 39.41249429011131], + [-7.456914703165921, 39.41275456406984], + [-7.456873619829366, 39.41298944461357], + [-7.456840753160321, 39.413167191526526], + [-7.4562409364515645, 39.41309736243639], + [-7.4562409364515645, 39.41327510907348], + [-7.456035519770694, 39.41351633592819], +]; + +export const simplifiedPathAt10Meters = [ + [-7.461250037923293, 39.408216536440364], + [-7.459998287096738, 39.40900601927663], + [-7.459998287096738, 39.40900601927663], +]; + +export const simplifiedAt5Meters = [ + [-7.461250037923293, 39.408216536440364], + [-7.461027762762766, 39.4082633743864], + [-7.46080491382207, 39.408308019802824], + [-7.460576541620585, 39.40833154644483], + [-7.460557901713584, 39.40848235747175], + [-7.460582446364902, 39.40865935623195], + [-7.460527465360764, 39.40881646130523], + [-7.460449236953846, 39.40896232807269], + [-7.4603300549284235, 39.40905367722735], + [-7.460213845514873, 39.40906881591038], + [-7.460213845514873, 39.40906881591038], +]; diff --git a/test/fixtures/animations/smoothed-keyframes.json b/test/fixtures/animations/smoothed-keyframes.json new file mode 100644 index 00000000..6056d19d --- /dev/null +++ b/test/fixtures/animations/smoothed-keyframes.json @@ -0,0 +1,2422 @@ +[ + { + "props": { + "propertyOne": 0, + "propertyTwo": 100, + "lng": 0.4534073770421304, + "lat": 0.4534073770421304, + "altitude": null + }, + "delta": 0, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.49874833480929737, + "lat": 0.49874833480929737, + "altitude": null + }, + "delta": 0.004545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.5440890618651185, + "lat": 0.5440890618651185, + "altitude": null + }, + "delta": 0.00909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.5894296895897722, + "lat": 0.5894296895897722, + "altitude": null + }, + "delta": 0.013636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.634770349363437, + "lat": 0.634770349363437, + "altitude": null + }, + "delta": 0.01818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.6801111725662913, + "lat": 0.6801111725662913, + "altitude": null + }, + "delta": 0.022727272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.7254522905785136, + "lat": 0.7254522905785136, + "altitude": null + }, + "delta": 0.02727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.7707938347802821, + "lat": 0.7707938347802821, + "altitude": null + }, + "delta": 0.031818181818181815, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.8161359365517756, + "lat": 0.8161359365517756, + "altitude": null + }, + "delta": 0.03636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.8614787272731722, + "lat": 0.8614787272731722, + "altitude": null + }, + "delta": 0.04090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.9068223383246503, + "lat": 0.9068223383246503, + "altitude": null + }, + "delta": 0.045454545454545456, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.9068223383246504, + "lat": 0.9068223383246504, + "altitude": null + }, + "delta": 0.05, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.9521667646056636, + "lat": 0.9521667646056636, + "altitude": null + }, + "delta": 0.05454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 0.9975118670364638, + "lat": 0.9975118670364638, + "altitude": null + }, + "delta": 0.05909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.042857576028427, + "lat": 1.042857576028427, + "altitude": null + }, + "delta": 0.06363636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.0882038219929302, + "lat": 1.0882038219929302, + "altitude": null + }, + "delta": 0.06818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.1335505353413495, + "lat": 1.1335505353413495, + "altitude": null + }, + "delta": 0.07272727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.1788976464850618, + "lat": 1.1788976464850618, + "altitude": null + }, + "delta": 0.07727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.224245085835443, + "lat": 1.224245085835443, + "altitude": null + }, + "delta": 0.08181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.26959278380387, + "lat": 1.26959278380387, + "altitude": null + }, + "delta": 0.08636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.314940670801719, + "lat": 1.314940670801719, + "altitude": null + }, + "delta": 0.09090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.3602886772403666, + "lat": 1.3602886772403666, + "altitude": null + }, + "delta": 0.09545454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 10, + "propertyTwo": 90, + "lng": 1.3602886772403666, + "lat": 1.3602886772403666, + "altitude": 10 + }, + "delta": 0.1, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.4056366195909356, + "lat": 1.4056366195909356, + "altitude": null + }, + "delta": 0.10454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.4509844680589181, + "lat": 1.4509844680589181, + "altitude": null + }, + "delta": 0.10909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.496332383657245, + "lat": 1.496332383657245, + "altitude": null + }, + "delta": 0.11363636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.5416805273988494, + "lat": 1.5416805273988494, + "altitude": null + }, + "delta": 0.11818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.5870290602966608, + "lat": 1.5870290602966608, + "altitude": null + }, + "delta": 0.12272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.6323781433636113, + "lat": 1.6323781433636113, + "altitude": null + }, + "delta": 0.12727272727272726, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.677727937612632, + "lat": 1.677727937612632, + "altitude": null + }, + "delta": 0.1318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.7230786040566548, + "lat": 1.7230786040566548, + "altitude": null + }, + "delta": 0.13636363636363635, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.7684303037086102, + "lat": 1.7684303037086102, + "altitude": null + }, + "delta": 0.1409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.8137831975814296, + "lat": 1.8137831975814296, + "altitude": null + }, + "delta": 0.14545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.8137831975814298, + "lat": 1.8137831975814298, + "altitude": null + }, + "delta": 0.15, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.8591372472785674, + "lat": 1.8591372472785674, + "altitude": null + }, + "delta": 0.15454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.904492264492235, + "lat": 1.904492264492235, + "altitude": null + }, + "delta": 0.1590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.9498481853685006, + "lat": 1.9498481853685006, + "altitude": null + }, + "delta": 0.16363636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 1.9952049460534351, + "lat": 1.9952049460534351, + "altitude": null + }, + "delta": 0.16818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.0405624826931064, + "lat": 2.0405624826931064, + "altitude": null + }, + "delta": 0.17272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.0859207314335846, + "lat": 2.0859207314335846, + "altitude": null + }, + "delta": 0.17727272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.131279628420938, + "lat": 2.131279628420938, + "altitude": null + }, + "delta": 0.18181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.176639109801237, + "lat": 2.176639109801237, + "altitude": null + }, + "delta": 0.18636363636363637, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.22199911172055, + "lat": 2.22199911172055, + "altitude": null + }, + "delta": 0.19090909090909092, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.267359570324946, + "lat": 2.267359570324946, + "altitude": null + }, + "delta": 0.19545454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 20, + "propertyTwo": 80, + "lng": 2.267359570324946, + "lat": 2.267359570324946, + "altitude": null + }, + "delta": 0.2, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.3127203756458514, + "lat": 2.3127203756458514, + "altitude": null + }, + "delta": 0.20454545454545456, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.3580815328085056, + "lat": 2.3580815328085056, + "altitude": null + }, + "delta": 0.20909090909090908, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.403443150599696, + "lat": 2.403443150599696, + "altitude": null + }, + "delta": 0.21363636363636362, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.4488053378062133, + "lat": 2.4488053378062133, + "altitude": null + }, + "delta": 0.21818181818181817, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.494168203214844, + "lat": 2.494168203214844, + "altitude": null + }, + "delta": 0.22272727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.5395318556123776, + "lat": 2.5395318556123776, + "altitude": null + }, + "delta": 0.22727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.584896403785602, + "lat": 2.584896403785602, + "altitude": null + }, + "delta": 0.2318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.6302619565213075, + "lat": 2.6302619565213075, + "altitude": null + }, + "delta": 0.23636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.675628622606281, + "lat": 2.675628622606281, + "altitude": null + }, + "delta": 0.2409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.720996510827311, + "lat": 2.720996510827311, + "altitude": null + }, + "delta": 0.24545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.720996510827311, + "lat": 2.720996510827311, + "altitude": null + }, + "delta": 0.25, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.7663654779901607, + "lat": 2.7663654779901607, + "altitude": null + }, + "delta": 0.2545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.8117353317265463, + "lat": 2.8117353317265463, + "altitude": null + }, + "delta": 0.2590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.857106107062186, + "lat": 2.857106107062186, + "altitude": null + }, + "delta": 0.2636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.9024778390228043, + "lat": 2.9024778390228043, + "altitude": null + }, + "delta": 0.2681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.947850562634119, + "lat": 2.947850562634119, + "altitude": null + }, + "delta": 0.2727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 2.9932243129218525, + "lat": 2.9932243129218525, + "altitude": null + }, + "delta": 0.2772727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.038599124911725, + "lat": 3.038599124911725, + "altitude": null + }, + "delta": 0.2818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.083975033629457, + "lat": 3.083975033629457, + "altitude": null + }, + "delta": 0.2863636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.1293520741007694, + "lat": 3.1293520741007694, + "altitude": null + }, + "delta": 0.2909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.174730281351382, + "lat": 3.174730281351382, + "altitude": null + }, + "delta": 0.29545454545454547, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 30, + "propertyTwo": 70, + "lng": 3.174730281351382, + "lat": 3.174730281351382, + "altitude": 50 + }, + "delta": 0.3, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.2201096974408503, + "lat": 3.2201096974408503, + "altitude": null + }, + "delta": 0.30454545454545456, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.2654903094126255, + "lat": 3.2654903094126255, + "altitude": null + }, + "delta": 0.3090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.310872069768266, + "lat": 3.310872069768266, + "altitude": null + }, + "delta": 0.31363636363636366, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.3562549310093406, + "lat": 3.3562549310093406, + "altitude": null + }, + "delta": 0.3181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.4016388456374087, + "lat": 3.4016388456374087, + "altitude": null + }, + "delta": 0.32272727272727275, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.4470237661540355, + "lat": 3.4470237661540355, + "altitude": null + }, + "delta": 0.32727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.4924096450607824, + "lat": 3.4924096450607824, + "altitude": null + }, + "delta": 0.33181818181818185, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.537796434859214, + "lat": 3.537796434859214, + "altitude": null + }, + "delta": 0.33636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.583184088050893, + "lat": 3.583184088050893, + "altitude": null + }, + "delta": 0.3409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.628572557137382, + "lat": 3.628572557137382, + "altitude": null + }, + "delta": 0.34545454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.628572557137382, + "lat": 3.628572557137382, + "altitude": null + }, + "delta": 0.35, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.6739615082805788, + "lat": 3.6739615082805788, + "altitude": null + }, + "delta": 0.35454545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.7193508052725064, + "lat": 3.7193508052725064, + "altitude": null + }, + "delta": 0.35909090909090907, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.7647406970599198, + "lat": 3.7647406970599198, + "altitude": null + }, + "delta": 0.36363636363636365, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.8101314325895803, + "lat": 3.8101314325895803, + "altitude": null + }, + "delta": 0.36818181818181817, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.8555232608082397, + "lat": 3.8555232608082397, + "altitude": null + }, + "delta": 0.37272727272727274, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.9009164306626576, + "lat": 3.9009164306626576, + "altitude": null + }, + "delta": 0.37727272727272726, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.9463111910995883, + "lat": 3.9463111910995883, + "altitude": null + }, + "delta": 0.38181818181818183, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 3.9917077910657888, + "lat": 3.9917077910657888, + "altitude": null + }, + "delta": 0.38636363636363635, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.037106479508015, + "lat": 4.037106479508015, + "altitude": null + }, + "delta": 0.39090909090909093, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.082507505373024, + "lat": 4.082507505373024, + "altitude": null + }, + "delta": 0.39545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 40, + "propertyTwo": 60, + "lng": 4.082507505373025, + "lat": 4.082507505373025, + "altitude": 40 + }, + "delta": 0.4, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.127911131344705, + "lat": 4.127911131344705, + "altitude": null + }, + "delta": 0.40454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.17331724519154, + "lat": 4.17331724519154, + "altitude": null + }, + "delta": 0.4090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.218725533487185, + "lat": 4.218725533487185, + "altitude": null + }, + "delta": 0.41363636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.264135682805298, + "lat": 4.264135682805298, + "altitude": null + }, + "delta": 0.41818181818181815, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.309547379719531, + "lat": 4.309547379719531, + "altitude": null + }, + "delta": 0.42272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.35496031080354, + "lat": 4.35496031080354, + "altitude": null + }, + "delta": 0.42727272727272725, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.40037416263098, + "lat": 4.40037416263098, + "altitude": null + }, + "delta": 0.4318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.445788621775508, + "lat": 4.445788621775508, + "altitude": null + }, + "delta": 0.43636363636363634, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.491203374810778, + "lat": 4.491203374810778, + "altitude": null + }, + "delta": 0.4409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.536618108310442, + "lat": 4.536618108310442, + "altitude": null + }, + "delta": 0.44545454545454544, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.536618108310442, + "lat": 4.536618108310442, + "altitude": null + }, + "delta": 0.45, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.582032242022935, + "lat": 4.582032242022935, + "altitude": null + }, + "delta": 0.45454545454545453, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.627445778621, + "lat": 4.627445778621, + "altitude": null + }, + "delta": 0.4590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.672859279064762, + "lat": 4.672859279064762, + "altitude": null + }, + "delta": 0.4636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.718273304314351, + "lat": 4.718273304314351, + "altitude": null + }, + "delta": 0.4681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.763688415329894, + "lat": 4.763688415329894, + "altitude": null + }, + "delta": 0.4727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.809105173071516, + "lat": 4.809105173071516, + "altitude": null + }, + "delta": 0.4772727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.854524138499343, + "lat": 4.854524138499343, + "altitude": null + }, + "delta": 0.4818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.899945872573505, + "lat": 4.899945872573505, + "altitude": null + }, + "delta": 0.4863636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.945370936254126, + "lat": 4.945370936254126, + "altitude": null + }, + "delta": 0.4909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 4.990799890501334, + "lat": 4.990799890501334, + "altitude": null + }, + "delta": 0.4954545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 50, + "propertyTwo": 50, + "lng": 4.990799890501334, + "lat": 4.990799890501334, + "altitude": null + }, + "delta": 0.5, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.0362330334149, + "lat": 5.0362330334149, + "altitude": null + }, + "delta": 0.5045454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.08166994886938, + "lat": 5.08166994886938, + "altitude": null + }, + "delta": 0.509090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.127110126487078, + "lat": 5.127110126487078, + "altitude": null + }, + "delta": 0.5136363636363637, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.172553055890298, + "lat": 5.172553055890298, + "altitude": null + }, + "delta": 0.5181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.217998226701346, + "lat": 5.217998226701346, + "altitude": null + }, + "delta": 0.5227272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.263445128542527, + "lat": 5.263445128542527, + "altitude": null + }, + "delta": 0.5272727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.308893251036142, + "lat": 5.308893251036142, + "altitude": null + }, + "delta": 0.5318181818181819, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.3543420838045, + "lat": 5.3543420838045, + "altitude": null + }, + "delta": 0.5363636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.399791116469903, + "lat": 5.399791116469903, + "altitude": null + }, + "delta": 0.5409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.445239838654655, + "lat": 5.445239838654655, + "altitude": null + }, + "delta": 0.5454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.445239838654655, + "lat": 5.445239838654655, + "altitude": null + }, + "delta": 0.55, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.4906878225191456, + "lat": 5.4906878225191456, + "altitude": null + }, + "delta": 0.5545454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.536135359655276, + "lat": 5.536135359655276, + "altitude": null + }, + "delta": 0.5590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.581583018832613, + "lat": 5.581583018832613, + "altitude": null + }, + "delta": 0.5636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.627031368820734, + "lat": 5.627031368820734, + "altitude": null + }, + "delta": 0.5681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.672480978389207, + "lat": 5.672480978389207, + "altitude": null + }, + "delta": 0.5727272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.71793241630761, + "lat": 5.71793241630761, + "altitude": null + }, + "delta": 0.5772727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.763386251345507, + "lat": 5.763386251345507, + "altitude": null + }, + "delta": 0.5818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.808843052272478, + "lat": 5.808843052272478, + "altitude": null + }, + "delta": 0.5863636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.85430338785809, + "lat": 5.85430338785809, + "altitude": null + }, + "delta": 0.5909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 60, + "propertyTwo": 40, + "lng": 5.899767826871917, + "lat": 5.899767826871917, + "altitude": 60 + }, + "delta": 0.5954545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.899767826871917, + "lat": 5.899767826871917, + "altitude": null + }, + "delta": 0.6, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.94523655139604, + "lat": 5.94523655139604, + "altitude": null + }, + "delta": 0.6045454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 5.990709072752057, + "lat": 5.990709072752057, + "altitude": null + }, + "delta": 0.6090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.036184953568817, + "lat": 6.036184953568817, + "altitude": null + }, + "delta": 0.6136363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.081663756475178, + "lat": 6.081663756475178, + "altitude": null + }, + "delta": 0.6181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.127145044099982, + "lat": 6.127145044099982, + "altitude": null + }, + "delta": 0.6227272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.172628379072085, + "lat": 6.172628379072085, + "altitude": null + }, + "delta": 0.6272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.2181133240203375, + "lat": 6.2181133240203375, + "altitude": null + }, + "delta": 0.6318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.263599441573589, + "lat": 6.263599441573589, + "altitude": null + }, + "delta": 0.6363636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.309086294360692, + "lat": 6.309086294360692, + "altitude": null + }, + "delta": 0.6409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.354573445010495, + "lat": 6.354573445010495, + "altitude": null + }, + "delta": 0.6454545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.354573445010495, + "lat": 6.354573445010495, + "altitude": null + }, + "delta": 0.65, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.400060592604306, + "lat": 6.400060592604306, + "altitude": null + }, + "delta": 0.6545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.4455480235351486, + "lat": 6.4455480235351486, + "altitude": null + }, + "delta": 0.6590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.491036181399458, + "lat": 6.491036181399458, + "altitude": null + }, + "delta": 0.6636363636363637, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.536525509793674, + "lat": 6.536525509793674, + "altitude": null + }, + "delta": 0.6681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.582016452314228, + "lat": 6.582016452314228, + "altitude": null + }, + "delta": 0.6727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.627509452557559, + "lat": 6.627509452557559, + "altitude": null + }, + "delta": 0.6772727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.673004954120101, + "lat": 6.673004954120101, + "altitude": null + }, + "delta": 0.6818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.718503400598288, + "lat": 6.718503400598288, + "altitude": null + }, + "delta": 0.6863636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.764005235588558, + "lat": 6.764005235588558, + "altitude": null + }, + "delta": 0.6909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 70, + "propertyTwo": 30, + "lng": 6.809510902687343, + "lat": 6.809510902687343, + "altitude": 3.141592653589793 + }, + "delta": 0.6954545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.809510902687344, + "lat": 6.809510902687344, + "altitude": null + }, + "delta": 0.7, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.855020421583971, + "lat": 6.855020421583971, + "altitude": null + }, + "delta": 0.7045454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.900533343191016, + "lat": 6.900533343191016, + "altitude": null + }, + "delta": 0.7090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.946049407939798, + "lat": 6.946049407939798, + "altitude": null + }, + "delta": 0.7136363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 6.9915683562616415, + "lat": 6.9915683562616415, + "altitude": null + }, + "delta": 0.7181818181818181, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.037089928587868, + "lat": 7.037089928587868, + "altitude": null + }, + "delta": 0.7227272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.082613865349797, + "lat": 7.082613865349797, + "altitude": null + }, + "delta": 0.7272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.128139906978751, + "lat": 7.128139906978751, + "altitude": null + }, + "delta": 0.7318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.173667793906052, + "lat": 7.173667793906052, + "altitude": null + }, + "delta": 0.7363636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.21919726656302, + "lat": 7.21919726656302, + "altitude": null + }, + "delta": 0.740909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.264728065380976, + "lat": 7.264728065380976, + "altitude": null + }, + "delta": 0.7454545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.264728065380976, + "lat": 7.264728065380976, + "altitude": null + }, + "delta": 0.75, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.31026010011371, + "lat": 7.31026010011371, + "altitude": null + }, + "delta": 0.7545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.355793589806769, + "lat": 7.355793589806769, + "altitude": null + }, + "delta": 0.759090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.401328738829113, + "lat": 7.401328738829113, + "altitude": null + }, + "delta": 0.7636363636363637, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.446865751549712, + "lat": 7.446865751549712, + "altitude": null + }, + "delta": 0.7681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.492404832337524, + "lat": 7.492404832337524, + "altitude": null + }, + "delta": 0.7727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.5379461855615135, + "lat": 7.5379461855615135, + "altitude": null + }, + "delta": 0.7772727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.583490015590643, + "lat": 7.583490015590643, + "altitude": null + }, + "delta": 0.7818181818181819, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.629036526793877, + "lat": 7.629036526793877, + "altitude": null + }, + "delta": 0.7863636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.674585923540177, + "lat": 7.674585923540177, + "altitude": null + }, + "delta": 0.7909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 80, + "propertyTwo": 20, + "lng": 7.720138410198507, + "lat": 7.720138410198507, + "altitude": 1000 + }, + "delta": 0.7954545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.720138410198507, + "lat": 7.720138410198507, + "altitude": null + }, + "delta": 0.8, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.765693754048753, + "lat": 7.765693754048753, + "altitude": null + }, + "delta": 0.8045454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.811251613863778, + "lat": 7.811251613863778, + "altitude": null + }, + "delta": 0.8090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.85681203125201, + "lat": 7.85681203125201, + "altitude": null + }, + "delta": 0.8136363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.9023750478218835, + "lat": 7.9023750478218835, + "altitude": null + }, + "delta": 0.8181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.947940705181826, + "lat": 7.947940705181826, + "altitude": null + }, + "delta": 0.8227272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 7.993509044940266, + "lat": 7.993509044940266, + "altitude": null + }, + "delta": 0.8272727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.039080108705637, + "lat": 8.039080108705637, + "altitude": null + }, + "delta": 0.8318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.084653938086365, + "lat": 8.084653938086365, + "altitude": null + }, + "delta": 0.8363636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.13023057469088, + "lat": 8.13023057469088, + "altitude": null + }, + "delta": 0.8409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.175810060127615, + "lat": 8.175810060127615, + "altitude": null + }, + "delta": 0.8454545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.175810060127615, + "lat": 8.175810060127615, + "altitude": null + }, + "delta": 0.85, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.221392609916313, + "lat": 8.221392609916313, + "altitude": null + }, + "delta": 0.8545454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.266978300640687, + "lat": 8.266978300640687, + "altitude": null + }, + "delta": 0.8590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.312566965505102, + "lat": 8.312566965505102, + "altitude": null + }, + "delta": 0.8636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.358158437713946, + "lat": 8.358158437713946, + "altitude": null + }, + "delta": 0.8681818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.403752550471587, + "lat": 8.403752550471587, + "altitude": null + }, + "delta": 0.8727272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.449349136982402, + "lat": 8.449349136982402, + "altitude": null + }, + "delta": 0.8772727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.494948030450766, + "lat": 8.494948030450766, + "altitude": null + }, + "delta": 0.8818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.540549064081056, + "lat": 8.540549064081056, + "altitude": null + }, + "delta": 0.8863636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.586152071077645, + "lat": 8.586152071077645, + "altitude": null + }, + "delta": 0.8909090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 90, + "propertyTwo": 10, + "lng": 8.63175688464491, + "lat": 8.63175688464491, + "altitude": null + }, + "delta": 0.8954545454545455, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.63175688464491, + "lat": 8.63175688464491, + "altitude": null + }, + "delta": 0.9, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.677362918763194, + "lat": 8.677362918763194, + "altitude": null + }, + "delta": 0.9045454545454545, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.72297002083926, + "lat": 8.72297002083926, + "altitude": null + }, + "delta": 0.9090909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.768578674217112, + "lat": 8.768578674217112, + "altitude": null + }, + "delta": 0.9136363636363637, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.814189362240766, + "lat": 8.814189362240766, + "altitude": null + }, + "delta": 0.9181818181818182, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.85980256825422, + "lat": 8.85980256825422, + "altitude": null + }, + "delta": 0.9227272727272727, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.90541877560149, + "lat": 8.90541877560149, + "altitude": null + }, + "delta": 0.9272727272727272, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.951038467626576, + "lat": 8.951038467626576, + "altitude": null + }, + "delta": 0.9318181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 8.99666212767349, + "lat": 8.99666212767349, + "altitude": null + }, + "delta": 0.9363636363636364, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.042290239086238, + "lat": 9.042290239086238, + "altitude": null + }, + "delta": 0.9409090909090909, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.087923285208824, + "lat": 9.087923285208824, + "altitude": null + }, + "delta": 0.9454545454545454, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.087923285208824, + "lat": 9.087923285208824, + "altitude": null + }, + "delta": 0.95, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.135617038273558, + "lat": 9.135617038273558, + "altitude": null + }, + "delta": 0.9545454545454546, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.186513092719418, + "lat": 9.186513092719418, + "altitude": null + }, + "delta": 0.9590909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.239240665200413, + "lat": 9.239240665200413, + "altitude": null + }, + "delta": 0.9636363636363636, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.292428972370562, + "lat": 9.292428972370562, + "altitude": null + }, + "delta": 0.9681818181818181, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.344707230883877, + "lat": 9.344707230883877, + "altitude": null + }, + "delta": 0.9727272727272728, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.394704657394373, + "lat": 9.394704657394373, + "altitude": null + }, + "delta": 0.9772727272727273, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.441050468556062, + "lat": 9.441050468556062, + "altitude": null + }, + "delta": 0.9818181818181818, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.482373881022962, + "lat": 9.482373881022962, + "altitude": null + }, + "delta": 0.9863636363636363, + "easing": "Linear" + }, + { + "props": { + "propertyOne": null, + "propertyTwo": null, + "lng": 9.517304111449084, + "lat": 9.517304111449084, + "altitude": null + }, + "delta": 0.990909090909091, + "easing": "Linear" + }, + { + "props": { + "propertyOne": 100, + "propertyTwo": 0, + "lng": 9.54447037648844, + "lat": 9.54447037648844, + "altitude": 0 + }, + "delta": 0.9954545454545455, + "easing": "Linear" + } +] \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index afb877d0..2db12f12 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "useDefineForClassFields": true, "module": "ESNext", "lib": [ - "es2021", + "es2023", "DOM", "DOM.Iterable" ], diff --git a/vite.config-e2e.ts b/vite.config-e2e.ts index 6bc14435..96ed65c6 100644 --- a/vite.config-e2e.ts +++ b/vite.config-e2e.ts @@ -6,6 +6,7 @@ export default defineConfig({ rollupOptions: { input: { mapLoad: 'public/mapLoad.html', + animatedRouteLayer: 'public/animatedRouteLayer.html', }, }, },