From 0f14e09473806e40b40d58ae4f3ca3bf0674e052 Mon Sep 17 00:00:00 2001 From: Eddie Ferrer Date: Wed, 15 Nov 2023 14:48:58 -0700 Subject: [PATCH] feat: wip map mega animation --- @kiva/kv-components/utils/mapAnimation.js | 246 ++++++++++++++++++ @kiva/kv-components/vue/KvMap.vue | 81 ++++++ .../vue/stories/KvMap.stories.js | 31 +++ 3 files changed, 358 insertions(+) create mode 100644 @kiva/kv-components/utils/mapAnimation.js diff --git a/@kiva/kv-components/utils/mapAnimation.js b/@kiva/kv-components/utils/mapAnimation.js new file mode 100644 index 00000000..d481cc94 --- /dev/null +++ b/@kiva/kv-components/utils/mapAnimation.js @@ -0,0 +1,246 @@ +/* eslint-disable import/prefer-default-export */ + +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 = 'marker'; + el.style.backgroundImage = `url(${marker.properties.image})`; + el.style.width = `${marker.properties.iconSize[0]}px`; + el.style.height = `${marker.properties.iconSize[1]}px`; + + // el.addEventListener('click', () => { + // window.alert(marker.properties.message); + // }); + + // add marker to map + // eslint-disable-next-line no-undef + new maplibregl.Marker({ element: el }) + .setLngLat(marker.geometry.coordinates) + .addTo(mapInstance); + }); +} +/** + * 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]] +*/ +function getCoordinatesBetween(startCoordinates, endCoordinates, numberOfSteps) { + 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.push([endCoordinates[0], endCoordinates[1]]); + return lineCoordinates; +} + +/** + * This function animates a series of lines from an array of starting coordinates to a single 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); + // eslint-disable-next-line no-plusplus + animationCounter++; + } else { + // This else block is for "removing the line from point1 to 2" + const coord = geojson.features[0].geometry.coordinates; + // remove 2 points at a time + 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); + }); + }); +} + +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..f87dff3a 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..e0498181 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: 3, + height: 600, + width: 1000, + lat: 0.913, + long: 100, + advancedAnimation, +};