diff --git a/@kiva/kv-components/tests/unit/specs/utils/mapAnimationUtils.spec.js b/@kiva/kv-components/tests/unit/specs/utils/mapAnimationUtils.spec.js
new file mode 100644
index 00000000..d58ecf54
--- /dev/null
+++ b/@kiva/kv-components/tests/unit/specs/utils/mapAnimationUtils.spec.js
@@ -0,0 +1,40 @@
+import {
+ getCoordinatesBetween,
+} from '../../../../utils/mapAnimation';
+
+describe('mapAnimation', () => {
+ describe('getCoordinatesBetween', () => {
+ it('should return empty array if inputs are invalid', () => {
+ expect(getCoordinatesBetween([100, 88], undefined, 10)).toStrictEqual([]);
+ expect(getCoordinatesBetween(undefined, undefined, 10)).toStrictEqual([]);
+ expect(getCoordinatesBetween(undefined, undefined, 0)).toStrictEqual([]);
+ expect(getCoordinatesBetween(undefined, [-89, -89], 0)).toStrictEqual([]);
+ });
+ it('should return an array of steps', () => {
+ expect(getCoordinatesBetween([0, 0], [100, 100], 10)).toStrictEqual(
+ [[0, 0], [10, 10], [20, 20], [30, 30], [40, 40], [50, 50], [60, 60], [70, 70], [80, 80], [100, 100]],
+ );
+ expect(getCoordinatesBetween([0, 0], [100, 100], 3)).toStrictEqual(
+ [[0, 0], [33.333333333333336, 33.333333333333336], [100, 100]],
+ );
+ expect(getCoordinatesBetween([0, 0], [100, 101], 2)).toStrictEqual(
+ [[0, 0], [100, 101]],
+ );
+ });
+ it('should handle negative numbers', () => {
+ expect(getCoordinatesBetween([-10, -30], [-90, -45.123], 3)).toStrictEqual(
+ [[-10, -30], [-36.66666666666667, -35.041], [-90, -45.123]],
+ );
+ });
+ it('last item should be our exact endpoint', () => {
+ expect(getCoordinatesBetween([0, 0], [100, -180], 3)).toStrictEqual(
+ [[0, 0], [33.333333333333336, -60], [100, -180]],
+ );
+ });
+ it('should handle long decimals', () => {
+ expect(getCoordinatesBetween([0.123, 0.999], [100.123, 101.666], 3)).toStrictEqual(
+ [[0.123, 0.999], [33.45633333333333, 34.55466666666667], [100.123, 101.666]],
+ );
+ });
+ });
+});
diff --git a/@kiva/kv-components/utils/mapAnimation.js b/@kiva/kv-components/utils/mapAnimation.js
new file mode 100644
index 00000000..67fbff30
--- /dev/null
+++ b/@kiva/kv-components/utils/mapAnimation.js
@@ -0,0 +1,272 @@
+/**
+ * Code to generate random coordinates
+ * */
+function getRandomInRange(from, to, fixed) {
+ return (Math.random() * (to - from) + from).toFixed(fixed) * 1;
+ // .toFixed() returns string, so ' * 1' is a trick to convert to number
+}
+const randomCoordinates = Array.from(
+ { length: 20 },
+ () => [getRandomInRange(-180, 180, 3), getRandomInRange(-90, 90, 3)],
+);
+
+/**
+ * Given 2 coordinates and the number of steps return an array of coordinates in between
+ * @param {Array} startCoordinates - starting coordinates in the format [latitude, longitude]
+ * @param {Array} endCoordinates - ending coordinates in the format [latitude, longitude]
+ * @param {Number} numberOfSteps - number of steps to take between the start and end coordinates
+ * @returns {Array} - array of coordinates in the format [[latitude, longitude], [latitude, longitude]]
+*/
+export function getCoordinatesBetween(startCoordinates, endCoordinates, numberOfSteps) {
+ // All invalid inputs should return an empty array
+ if (!startCoordinates
+ || !endCoordinates
+ || !numberOfSteps
+ || numberOfSteps < 1
+ || !Array.isArray(startCoordinates)
+ || !Array.isArray(endCoordinates)
+ || startCoordinates.length !== 2 || endCoordinates.length !== 2) {
+ return [];
+ }
+ const diffX = endCoordinates[0] - startCoordinates[0];
+ const diffY = endCoordinates[1] - startCoordinates[1];
+
+ const sfX = diffX / numberOfSteps;
+ const sfY = diffY / numberOfSteps;
+
+ let i = 0;
+ let j = 0;
+
+ const lineCoordinates = [];
+
+ while (Math.abs(i) < Math.abs(diffX) || Math.abs(j) < Math.abs(diffY)) {
+ lineCoordinates.push([startCoordinates[0] + i, startCoordinates[1] + j]);
+
+ if (Math.abs(i) < Math.abs(diffX)) {
+ i += sfX;
+ }
+
+ if (Math.abs(j) < Math.abs(diffY)) {
+ j += sfY;
+ }
+ }
+ // Because of rounding errors, lets push the exact end coordinates
+ // as the last item in the array to make sure the line ends precisely
+ lineCoordinates[lineCoordinates.length - 1] = [endCoordinates[0], endCoordinates[1]];
+ return lineCoordinates;
+}
+
+/**
+ * This function animates a series of lines from an array of starting coordinates to a single end point
+ * then animates removing the line from the origin to the end point
+ * returns a promise when the animation is complete
+ * @param {Map Instance} mapInstance - the map instance
+ * @param {Array} originPoints - array of starting coordinates in the format [[latitude, longitude], [latitude, longitude]]
+ * @param {Array} endPoint - single end point in the format [latitude, longitude]
+ * @returns {Promise} - promise that resolves when the animation is complete
+*/
+function animateLines(mapInstance, originPoints, endPoint) {
+ const speedFactor = 100; // number of frames per degree, controls animation speed
+ return new Promise((resolve) => {
+ // EndPoint
+ mapInstance.addSource('endpoint', {
+ type: 'geojson',
+ data: {
+ type: 'Point',
+ coordinates: [
+ endPoint[0], endPoint[1],
+ ],
+ },
+ });
+
+ const lineFlight = (startCoordinates, endCoordinates, index, lastLine = false) => {
+ const lineCoordinates = getCoordinatesBetween(startCoordinates, endCoordinates, speedFactor);
+ let animationCounter = 0;
+
+ // Create a GeoJSON source with an empty lineString.
+ const geojson = {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature',
+ geometry: {
+ type: 'LineString',
+ coordinates: [],
+ },
+ }],
+ };
+
+ // Start Point
+ mapInstance.addSource(`startPoint${index}`, {
+ type: 'geojson',
+ data: {
+ type: 'Point',
+ coordinates: [
+ startCoordinates[0], startCoordinates[1],
+ ],
+ },
+ });
+
+ // Line
+ mapInstance.addLayer({
+ id: `line-animation${index}`,
+ type: 'line',
+ source: {
+ type: 'geojson',
+ data: geojson,
+ },
+ layout: {
+ 'line-cap': 'round',
+ 'line-join': 'round',
+ },
+ paint: {
+ 'line-color': '#277056',
+ 'line-width': 2,
+ },
+ });
+
+ const animateLine = () => {
+ if (animationCounter < lineCoordinates.length) {
+ geojson.features[0].geometry.coordinates.push(lineCoordinates[animationCounter]);
+ mapInstance.getSource(`line-animation${index}`).setData(geojson);
+
+ requestAnimationFrame(animateLine);
+ animationCounter += 1;
+ } else {
+ // This else block is for animating line removal from start to end
+ const coord = geojson.features[0].geometry.coordinates;
+ // remove 2 points at a time so the removal is twice as fast as the line creation
+ coord.shift();
+ coord.shift();
+
+ if (coord.length > 0) {
+ geojson.features[0].geometry.coordinates = coord;
+ mapInstance.getSource(`line-animation${index}`).setData(geojson);
+ requestAnimationFrame(animateLine);
+ } else {
+ // remove all sources to allow for new animation
+ mapInstance.removeLayer(`line-animation${index}`);
+ mapInstance.removeSource(`line-animation${index}`);
+ mapInstance.removeSource(`startPoint${index}`);
+
+ if (lastLine) {
+ mapInstance.removeSource('endpoint');
+ resolve();
+ }
+ }
+ }
+ };
+
+ animateLine();
+ };
+
+ originPoints.forEach((coordinate, index) => {
+ lineFlight(coordinate, endPoint, index, index === originPoints.length - 1);
+ });
+ });
+}
+
+/**
+ * This function generates a map marker for each borrowerPoint
+ * @param {Map Instance} mapInstance - the map instance
+ * @param {Array} borrowerPoints - array of borrower objects
+ * @returns {void}
+ * */
+export function generateMapMarkers(mapInstance, borrowerPoints) {
+ const geojson = {
+ type: 'FeatureCollection',
+ };
+
+ geojson.features = borrowerPoints.map((borrower) => ({
+ type: 'Feature',
+ properties: {
+ message: 'test',
+ image: borrower.image,
+ iconSize: [80, 80],
+ },
+ geometry: {
+ type: 'Point',
+ coordinates: borrower.location,
+ },
+ }));
+ // add markers to map
+ geojson.features.forEach((marker) => {
+ // create a DOM element for the marker
+ const el = document.createElement('div');
+ el.className = 'map-marker';
+ el.style.backgroundImage = `url(${marker.properties.image})`;
+ el.style.width = `${marker.properties.iconSize[0]}px`;
+ el.style.height = `${marker.properties.iconSize[1]}px`;
+
+ // Possible place to add an event listener
+ // el.addEventListener('click', () => {
+ // window.alert(marker.properties.message);
+ // });
+
+ // add marker to map
+ // maplibregl should be defined in the KvMap component
+ // eslint-disable-next-line no-undef
+ new maplibregl.Marker({ element: el })
+ .setLngLat(marker.geometry.coordinates)
+ .addTo(mapInstance);
+ });
+}
+
+/**
+ * This function animates a series of lines from an array of starting coordinates to a single end point
+ * then animates removing the line from the origin to the end point
+ * then flies to the next point in the array and repeats the animation
+ * returns a promise when the animation is complete
+ * @param {Map Instance} mapInstance - the map instance
+ * @param {Array} borrowerPoints - array of borrower objects
+ * @returns {Promise} - promise that resolves when the animation is complete
+ * */
+export function animationCoordinator(mapInstance, borrowerPoints) {
+ return new Promise((resolve) => {
+ const destinationPoints = borrowerPoints.map((borrower) => borrower.location);
+ const totalNumberOfPoints = destinationPoints.length;
+ let currentPointIndex = 0;
+
+ const flyToPoint = (index) => {
+ mapInstance.flyTo({
+ // These options control the ending camera position: centered at
+ // the target, at zoom level 9, and north up.
+ center: destinationPoints[index],
+ zoom: 4,
+ bearing: 0,
+
+ // These options control the flight curve, making it move
+ // slowly and zoom out almost completely before starting
+ // to pan.
+ speed: 0.9, // make the flying slow
+ curve: 1, // change the speed at which it zooms out
+
+ // This can be any easing function: it takes a number between
+ // 0 and 1 and returns another number between 0 and 1.
+ easing(t) {
+ return t;
+ },
+
+ // this animation is considered essential with respect to prefers-reduced-motion
+ essential: true,
+ }, { flyEnd: true });
+ };
+
+ // This will trigger the next steps in the animation chain
+ mapInstance.on('moveend', (event) => {
+ if (event.flyEnd === true) {
+ animateLines(mapInstance, randomCoordinates, destinationPoints[currentPointIndex])
+ .then(() => {
+ if (currentPointIndex < totalNumberOfPoints - 1) {
+ currentPointIndex += 1;
+ flyToPoint(currentPointIndex);
+ } else {
+ resolve();
+ }
+ });
+ }
+ });
+
+ // fly to point 1
+ flyToPoint(currentPointIndex);
+ });
+}
diff --git a/@kiva/kv-components/vue/KvMap.vue b/@kiva/kv-components/vue/KvMap.vue
index cd95868c..411adff9 100644
--- a/@kiva/kv-components/vue/KvMap.vue
+++ b/@kiva/kv-components/vue/KvMap.vue
@@ -13,6 +13,8 @@
+
+
diff --git a/@kiva/kv-components/vue/stories/KvMap.stories.js b/@kiva/kv-components/vue/stories/KvMap.stories.js
index 7c6b077b..1e9138a1 100644
--- a/@kiva/kv-components/vue/stories/KvMap.stories.js
+++ b/@kiva/kv-components/vue/stories/KvMap.stories.js
@@ -13,6 +13,7 @@ export default {
useLeaflet: false,
width: null,
zoomLevel: 4,
+ advancedAnimation: {},
},
};
@@ -30,6 +31,7 @@ const Template = (args, { argTypes }) => ({
:use-leaflet="useLeaflet"
:width="width"
:zoom-level="zoomLevel"
+ :advanced-animation="advancedAnimation"
/>`,
});
@@ -67,3 +69,32 @@ Leaflet.args = {
useLeaflet: true,
zoomLevel: 6,
};
+
+export const AdvancedAnimation = Template.bind({});
+const advancedAnimation = {
+ borrowerPoints: [
+ {
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/e60a3d61ff052d60991c5d6bbf4a45d3.jpg',
+ location: [-77.032, 38.913],
+ },
+ {
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/6101929097c6e5de48232a4d1ae3b71c.jpg',
+ location: [41.402, 7.160],
+ },
+ {
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/11e018ee3d8b9c5adee459c16a29d264.jpg',
+ location: [-73.356596, 3.501],
+ },
+ ],
+};
+AdvancedAnimation.args = {
+ initialZoom: null,
+ mapId: 5,
+ useLeaflet: false,
+ zoomLevel: 2,
+ height: 600,
+ width: 1000,
+ lat: 21.096,
+ long: -31.690,
+ advancedAnimation,
+};