Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: map mega animation #315

Merged
merged 1 commit into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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]],
);
});
});
});
272 changes: 272 additions & 0 deletions @kiva/kv-components/utils/mapAnimation.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading
Loading