@kiva/kv-components 3.46.3 → 3.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.48.0](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.47.0...@kiva/kv-components@3.48.0) (2023-11-20)
7
+
8
+
9
+ ### Features
10
+
11
+ * map mega animation ([#315](https://github.com/kiva/kv-ui-elements/issues/315)) ([5adfe9c](https://github.com/kiva/kv-ui-elements/commit/5adfe9c5d7db3b05efa23962025ba5440e8a2e4a))
12
+
13
+
14
+
15
+
16
+
17
+ # [3.47.0](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.46.3...@kiva/kv-components@3.47.0) (2023-10-23)
18
+
19
+
20
+ ### Features
21
+
22
+ * prioritizing matching text in loan tag component ([#308](https://github.com/kiva/kv-ui-elements/issues/308)) ([2ac6ec1](https://github.com/kiva/kv-ui-elements/commit/2ac6ec129733eccaeeca93edd32f8e76d8f8f906))
23
+
24
+
25
+
26
+
27
+
6
28
  ## [3.46.3](https://github.com/kiva/kv-ui-elements/compare/@kiva/kv-components@3.46.2...@kiva/kv-components@3.46.3) (2023-10-17)
7
29
 
8
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kiva/kv-components",
3
- "version": "3.46.3",
3
+ "version": "3.48.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -75,5 +75,5 @@
75
75
  "optional": true
76
76
  }
77
77
  },
78
- "gitHead": "6fc84d328af54b57e6f9b9b1c409ce4a695d2b10"
78
+ "gitHead": "d6d10fe82c51bb9bd2bab93e475dac025d1298e9"
79
79
  }
@@ -0,0 +1,40 @@
1
+ import {
2
+ getCoordinatesBetween,
3
+ } from '../../../../utils/mapAnimation';
4
+
5
+ describe('mapAnimation', () => {
6
+ describe('getCoordinatesBetween', () => {
7
+ it('should return empty array if inputs are invalid', () => {
8
+ expect(getCoordinatesBetween([100, 88], undefined, 10)).toStrictEqual([]);
9
+ expect(getCoordinatesBetween(undefined, undefined, 10)).toStrictEqual([]);
10
+ expect(getCoordinatesBetween(undefined, undefined, 0)).toStrictEqual([]);
11
+ expect(getCoordinatesBetween(undefined, [-89, -89], 0)).toStrictEqual([]);
12
+ });
13
+ it('should return an array of steps', () => {
14
+ expect(getCoordinatesBetween([0, 0], [100, 100], 10)).toStrictEqual(
15
+ [[0, 0], [10, 10], [20, 20], [30, 30], [40, 40], [50, 50], [60, 60], [70, 70], [80, 80], [100, 100]],
16
+ );
17
+ expect(getCoordinatesBetween([0, 0], [100, 100], 3)).toStrictEqual(
18
+ [[0, 0], [33.333333333333336, 33.333333333333336], [100, 100]],
19
+ );
20
+ expect(getCoordinatesBetween([0, 0], [100, 101], 2)).toStrictEqual(
21
+ [[0, 0], [100, 101]],
22
+ );
23
+ });
24
+ it('should handle negative numbers', () => {
25
+ expect(getCoordinatesBetween([-10, -30], [-90, -45.123], 3)).toStrictEqual(
26
+ [[-10, -30], [-36.66666666666667, -35.041], [-90, -45.123]],
27
+ );
28
+ });
29
+ it('last item should be our exact endpoint', () => {
30
+ expect(getCoordinatesBetween([0, 0], [100, -180], 3)).toStrictEqual(
31
+ [[0, 0], [33.333333333333336, -60], [100, -180]],
32
+ );
33
+ });
34
+ it('should handle long decimals', () => {
35
+ expect(getCoordinatesBetween([0.123, 0.999], [100.123, 101.666], 3)).toStrictEqual(
36
+ [[0.123, 0.999], [33.45633333333333, 34.55466666666667], [100.123, 101.666]],
37
+ );
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Code to generate random coordinates
3
+ * */
4
+ function getRandomInRange(from, to, fixed) {
5
+ return (Math.random() * (to - from) + from).toFixed(fixed) * 1;
6
+ // .toFixed() returns string, so ' * 1' is a trick to convert to number
7
+ }
8
+ const randomCoordinates = Array.from(
9
+ { length: 20 },
10
+ () => [getRandomInRange(-180, 180, 3), getRandomInRange(-90, 90, 3)],
11
+ );
12
+
13
+ /**
14
+ * Given 2 coordinates and the number of steps return an array of coordinates in between
15
+ * @param {Array} startCoordinates - starting coordinates in the format [latitude, longitude]
16
+ * @param {Array} endCoordinates - ending coordinates in the format [latitude, longitude]
17
+ * @param {Number} numberOfSteps - number of steps to take between the start and end coordinates
18
+ * @returns {Array} - array of coordinates in the format [[latitude, longitude], [latitude, longitude]]
19
+ */
20
+ export function getCoordinatesBetween(startCoordinates, endCoordinates, numberOfSteps) {
21
+ // All invalid inputs should return an empty array
22
+ if (!startCoordinates
23
+ || !endCoordinates
24
+ || !numberOfSteps
25
+ || numberOfSteps < 1
26
+ || !Array.isArray(startCoordinates)
27
+ || !Array.isArray(endCoordinates)
28
+ || startCoordinates.length !== 2 || endCoordinates.length !== 2) {
29
+ return [];
30
+ }
31
+ const diffX = endCoordinates[0] - startCoordinates[0];
32
+ const diffY = endCoordinates[1] - startCoordinates[1];
33
+
34
+ const sfX = diffX / numberOfSteps;
35
+ const sfY = diffY / numberOfSteps;
36
+
37
+ let i = 0;
38
+ let j = 0;
39
+
40
+ const lineCoordinates = [];
41
+
42
+ while (Math.abs(i) < Math.abs(diffX) || Math.abs(j) < Math.abs(diffY)) {
43
+ lineCoordinates.push([startCoordinates[0] + i, startCoordinates[1] + j]);
44
+
45
+ if (Math.abs(i) < Math.abs(diffX)) {
46
+ i += sfX;
47
+ }
48
+
49
+ if (Math.abs(j) < Math.abs(diffY)) {
50
+ j += sfY;
51
+ }
52
+ }
53
+ // Because of rounding errors, lets push the exact end coordinates
54
+ // as the last item in the array to make sure the line ends precisely
55
+ lineCoordinates[lineCoordinates.length - 1] = [endCoordinates[0], endCoordinates[1]];
56
+ return lineCoordinates;
57
+ }
58
+
59
+ /**
60
+ * This function animates a series of lines from an array of starting coordinates to a single end point
61
+ * then animates removing the line from the origin to the end point
62
+ * returns a promise when the animation is complete
63
+ * @param {Map Instance} mapInstance - the map instance
64
+ * @param {Array} originPoints - array of starting coordinates in the format [[latitude, longitude], [latitude, longitude]]
65
+ * @param {Array} endPoint - single end point in the format [latitude, longitude]
66
+ * @returns {Promise} - promise that resolves when the animation is complete
67
+ */
68
+ function animateLines(mapInstance, originPoints, endPoint) {
69
+ const speedFactor = 100; // number of frames per degree, controls animation speed
70
+ return new Promise((resolve) => {
71
+ // EndPoint
72
+ mapInstance.addSource('endpoint', {
73
+ type: 'geojson',
74
+ data: {
75
+ type: 'Point',
76
+ coordinates: [
77
+ endPoint[0], endPoint[1],
78
+ ],
79
+ },
80
+ });
81
+
82
+ const lineFlight = (startCoordinates, endCoordinates, index, lastLine = false) => {
83
+ const lineCoordinates = getCoordinatesBetween(startCoordinates, endCoordinates, speedFactor);
84
+ let animationCounter = 0;
85
+
86
+ // Create a GeoJSON source with an empty lineString.
87
+ const geojson = {
88
+ type: 'FeatureCollection',
89
+ features: [{
90
+ type: 'Feature',
91
+ geometry: {
92
+ type: 'LineString',
93
+ coordinates: [],
94
+ },
95
+ }],
96
+ };
97
+
98
+ // Start Point
99
+ mapInstance.addSource(`startPoint${index}`, {
100
+ type: 'geojson',
101
+ data: {
102
+ type: 'Point',
103
+ coordinates: [
104
+ startCoordinates[0], startCoordinates[1],
105
+ ],
106
+ },
107
+ });
108
+
109
+ // Line
110
+ mapInstance.addLayer({
111
+ id: `line-animation${index}`,
112
+ type: 'line',
113
+ source: {
114
+ type: 'geojson',
115
+ data: geojson,
116
+ },
117
+ layout: {
118
+ 'line-cap': 'round',
119
+ 'line-join': 'round',
120
+ },
121
+ paint: {
122
+ 'line-color': '#277056',
123
+ 'line-width': 2,
124
+ },
125
+ });
126
+
127
+ const animateLine = () => {
128
+ if (animationCounter < lineCoordinates.length) {
129
+ geojson.features[0].geometry.coordinates.push(lineCoordinates[animationCounter]);
130
+ mapInstance.getSource(`line-animation${index}`).setData(geojson);
131
+
132
+ requestAnimationFrame(animateLine);
133
+ animationCounter += 1;
134
+ } else {
135
+ // This else block is for animating line removal from start to end
136
+ const coord = geojson.features[0].geometry.coordinates;
137
+ // remove 2 points at a time so the removal is twice as fast as the line creation
138
+ coord.shift();
139
+ coord.shift();
140
+
141
+ if (coord.length > 0) {
142
+ geojson.features[0].geometry.coordinates = coord;
143
+ mapInstance.getSource(`line-animation${index}`).setData(geojson);
144
+ requestAnimationFrame(animateLine);
145
+ } else {
146
+ // remove all sources to allow for new animation
147
+ mapInstance.removeLayer(`line-animation${index}`);
148
+ mapInstance.removeSource(`line-animation${index}`);
149
+ mapInstance.removeSource(`startPoint${index}`);
150
+
151
+ if (lastLine) {
152
+ mapInstance.removeSource('endpoint');
153
+ resolve();
154
+ }
155
+ }
156
+ }
157
+ };
158
+
159
+ animateLine();
160
+ };
161
+
162
+ originPoints.forEach((coordinate, index) => {
163
+ lineFlight(coordinate, endPoint, index, index === originPoints.length - 1);
164
+ });
165
+ });
166
+ }
167
+
168
+ /**
169
+ * This function generates a map marker for each borrowerPoint
170
+ * @param {Map Instance} mapInstance - the map instance
171
+ * @param {Array} borrowerPoints - array of borrower objects
172
+ * @returns {void}
173
+ * */
174
+ export function generateMapMarkers(mapInstance, borrowerPoints) {
175
+ const geojson = {
176
+ type: 'FeatureCollection',
177
+ };
178
+
179
+ geojson.features = borrowerPoints.map((borrower) => ({
180
+ type: 'Feature',
181
+ properties: {
182
+ message: 'test',
183
+ image: borrower.image,
184
+ iconSize: [80, 80],
185
+ },
186
+ geometry: {
187
+ type: 'Point',
188
+ coordinates: borrower.location,
189
+ },
190
+ }));
191
+ // add markers to map
192
+ geojson.features.forEach((marker) => {
193
+ // create a DOM element for the marker
194
+ const el = document.createElement('div');
195
+ el.className = 'map-marker';
196
+ el.style.backgroundImage = `url(${marker.properties.image})`;
197
+ el.style.width = `${marker.properties.iconSize[0]}px`;
198
+ el.style.height = `${marker.properties.iconSize[1]}px`;
199
+
200
+ // Possible place to add an event listener
201
+ // el.addEventListener('click', () => {
202
+ // window.alert(marker.properties.message);
203
+ // });
204
+
205
+ // add marker to map
206
+ // maplibregl should be defined in the KvMap component
207
+ // eslint-disable-next-line no-undef
208
+ new maplibregl.Marker({ element: el })
209
+ .setLngLat(marker.geometry.coordinates)
210
+ .addTo(mapInstance);
211
+ });
212
+ }
213
+
214
+ /**
215
+ * This function animates a series of lines from an array of starting coordinates to a single end point
216
+ * then animates removing the line from the origin to the end point
217
+ * then flies to the next point in the array and repeats the animation
218
+ * returns a promise when the animation is complete
219
+ * @param {Map Instance} mapInstance - the map instance
220
+ * @param {Array} borrowerPoints - array of borrower objects
221
+ * @returns {Promise} - promise that resolves when the animation is complete
222
+ * */
223
+ export function animationCoordinator(mapInstance, borrowerPoints) {
224
+ return new Promise((resolve) => {
225
+ const destinationPoints = borrowerPoints.map((borrower) => borrower.location);
226
+ const totalNumberOfPoints = destinationPoints.length;
227
+ let currentPointIndex = 0;
228
+
229
+ const flyToPoint = (index) => {
230
+ mapInstance.flyTo({
231
+ // These options control the ending camera position: centered at
232
+ // the target, at zoom level 9, and north up.
233
+ center: destinationPoints[index],
234
+ zoom: 4,
235
+ bearing: 0,
236
+
237
+ // These options control the flight curve, making it move
238
+ // slowly and zoom out almost completely before starting
239
+ // to pan.
240
+ speed: 0.9, // make the flying slow
241
+ curve: 1, // change the speed at which it zooms out
242
+
243
+ // This can be any easing function: it takes a number between
244
+ // 0 and 1 and returns another number between 0 and 1.
245
+ easing(t) {
246
+ return t;
247
+ },
248
+
249
+ // this animation is considered essential with respect to prefers-reduced-motion
250
+ essential: true,
251
+ }, { flyEnd: true });
252
+ };
253
+
254
+ // This will trigger the next steps in the animation chain
255
+ mapInstance.on('moveend', (event) => {
256
+ if (event.flyEnd === true) {
257
+ animateLines(mapInstance, randomCoordinates, destinationPoints[currentPointIndex])
258
+ .then(() => {
259
+ if (currentPointIndex < totalNumberOfPoints - 1) {
260
+ currentPointIndex += 1;
261
+ flyToPoint(currentPointIndex);
262
+ } else {
263
+ resolve();
264
+ }
265
+ });
266
+ }
267
+ });
268
+
269
+ // fly to point 1
270
+ flyToPoint(currentPointIndex);
271
+ });
272
+ }
package/vue/KvLoanTag.vue CHANGED
@@ -50,14 +50,14 @@ export default {
50
50
  return numeral(this.loan?.loanAmount).subtract(fundedAmount).subtract(reservedAmount).value();
51
51
  },
52
52
  variation() {
53
- if (this.isLseLoan) {
53
+ if (this.loan?.matchingText) {
54
+ return 'matched-loan';
55
+ } if (this.isLseLoan) {
54
56
  return 'lse-loan';
55
57
  } if (differenceInDays(parseISO(this.loan?.plannedExpirationDate), Date.now()) <= 3) {
56
58
  return 'ending-soon';
57
59
  } if (this.amountLeft < 100 && this.amountLeft >= 0) {
58
60
  return 'almost-funded';
59
- } if (this.loan?.matchingText) {
60
- return 'matched-loan';
61
61
  }
62
62
  return null;
63
63
  },
package/vue/KvMap.vue CHANGED
@@ -13,6 +13,8 @@
13
13
  </template>
14
14
 
15
15
  <script>
16
+ import { animationCoordinator, generateMapMarkers } from '../utils/mapAnimation';
17
+
16
18
  export default {
17
19
  name: 'KvMap',
18
20
  props: {
@@ -87,6 +89,32 @@ export default {
87
89
  type: Number,
88
90
  default: 4,
89
91
  },
92
+ /**
93
+ * Borrower points object.
94
+ * If this object is present, the advanced animation will be triggered
95
+ * Sample object:
96
+ * {
97
+ borrowerPoints: [
98
+ {
99
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/e60a3d61ff052d60991c5d6bbf4a45d3.jpg',
100
+ location: [-77.032, 38.913],
101
+ },
102
+ {
103
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/6101929097c6e5de48232a4d1ae3b71c.jpg',
104
+ location: [41.402, 7.160],
105
+ },
106
+ {
107
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/11e018ee3d8b9c5adee459c16a29d264.jpg',
108
+ location: [-73.356596, 3.501],
109
+ },
110
+ ],
111
+ * }
112
+ */
113
+ advancedAnimation: {
114
+ type: Object,
115
+ required: false,
116
+ default: () => ({}),
117
+ },
90
118
  },
91
119
  data() {
92
120
  return {
@@ -174,10 +202,18 @@ export default {
174
202
  this.wrapperObserver = this.createIntersectionObserver({
175
203
  targets: [this.$refs?.[this.refString]],
176
204
  callback: (entries) => {
205
+ // only activate autoZoom if we have an initialZoom set
177
206
  entries.forEach((entry) => {
178
207
  if (entry.target === this.$refs?.[this.refString] && !this.zoomActive) {
179
208
  if (entry.intersectionRatio > 0) {
180
- this.activateZoom();
209
+ // activate zoom
210
+ if (this.initialZoom !== null) {
211
+ this.activateZoom();
212
+ }
213
+ // animate map
214
+ if (this.advancedAnimation?.borrowerPoints) {
215
+ this.animateMap();
216
+ }
181
217
  }
182
218
  }
183
219
  });
@@ -291,13 +327,40 @@ export default {
291
327
  dragRotate: false,
292
328
  });
293
329
 
294
- // signify map has loaded
295
- this.mapLoaded = true;
330
+ this.mapInstance.on('load', () => {
331
+ // signify map has loaded
332
+ this.mapLoaded = true;
333
+ // Create wrapper observer to watch for map entering viewport
334
+ if (this.initialZoom !== null || this.advancedAnimation?.borrowerPoints) {
335
+ this.createWrapperObserver();
336
+ }
337
+ });
338
+ },
339
+ animateMap() {
340
+ // remove country labels
341
+ this.mapInstance.style.stylesheet.layers.forEach((layer) => {
342
+ if (layer.type === 'symbol') {
343
+ this.mapInstance.removeLayer(layer.id);
344
+ }
345
+ });
346
+ // generate map markers for borrower points
347
+ generateMapMarkers(this.mapInstance, this.advancedAnimation.borrowerPoints);
296
348
 
297
- // only activate autoZoom if we have an initialZoom set
298
- if (this.initialZoom !== null) {
299
- this.createWrapperObserver();
300
- }
349
+ // wait 500 ms before calling the animation coordinator promise
350
+ // to allow the map to scroll into view
351
+ setTimeout(() => {
352
+ animationCoordinator(this.mapInstance, this.advancedAnimation.borrowerPoints)
353
+ .then(() => {
354
+ // when animation is complete reset map to component properties
355
+ this.mapInstance.dragPan.enable();
356
+ this.mapInstance.scrollZoom.enable();
357
+ this.mapInstance.scrollZoom.enable();
358
+ this.mapInstance.easeTo({
359
+ center: [this.long, this.lat],
360
+ zoom: this.initialZoom || this.zoomLevel,
361
+ });
362
+ });
363
+ }, 500);
301
364
  },
302
365
  checkIntersectionObserverSupport() {
303
366
  if (typeof window === 'undefined'
@@ -348,3 +411,40 @@ export default {
348
411
  },
349
412
  };
350
413
  </script>
414
+
415
+ <style>
416
+ /* Styles for animation map markers defined in @kiva/kv-components/utils/mapAnimation.js */
417
+ .map-marker {
418
+ margin-top: -77px;
419
+ margin-left: 35px;
420
+ display: block;
421
+ border: none;
422
+ border-radius: 50%;
423
+ cursor: pointer;
424
+ padding: 0;
425
+ }
426
+
427
+ .map-marker::after {
428
+ content: '';
429
+ position: absolute;
430
+ top: -8px;
431
+ left: -8px;
432
+ right: -8px;
433
+ bottom: -8px;
434
+ border-radius: 50%;
435
+ border: 4px solid #000;
436
+ }
437
+
438
+ .map-marker::before {
439
+ content: "";
440
+ width: 0;
441
+ height: 0;
442
+ left: -13px;
443
+ bottom: -32px;
444
+ border: 9px solid transparent;
445
+ border-left: 40px solid #000;
446
+ transform: rotate(114deg);
447
+ position: absolute;
448
+ z-index: -1;
449
+ }
450
+ </style>
@@ -13,6 +13,7 @@ export default {
13
13
  useLeaflet: false,
14
14
  width: null,
15
15
  zoomLevel: 4,
16
+ advancedAnimation: {},
16
17
  },
17
18
  };
18
19
 
@@ -30,6 +31,7 @@ const Template = (args, { argTypes }) => ({
30
31
  :use-leaflet="useLeaflet"
31
32
  :width="width"
32
33
  :zoom-level="zoomLevel"
34
+ :advanced-animation="advancedAnimation"
33
35
  />`,
34
36
  });
35
37
 
@@ -67,3 +69,32 @@ Leaflet.args = {
67
69
  useLeaflet: true,
68
70
  zoomLevel: 6,
69
71
  };
72
+
73
+ export const AdvancedAnimation = Template.bind({});
74
+ const advancedAnimation = {
75
+ borrowerPoints: [
76
+ {
77
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/e60a3d61ff052d60991c5d6bbf4a45d3.jpg',
78
+ location: [-77.032, 38.913],
79
+ },
80
+ {
81
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/6101929097c6e5de48232a4d1ae3b71c.jpg',
82
+ location: [41.402, 7.160],
83
+ },
84
+ {
85
+ image: 'https://www-kiva-org.freetls.fastly.net/img/w80h80fz50/11e018ee3d8b9c5adee459c16a29d264.jpg',
86
+ location: [-73.356596, 3.501],
87
+ },
88
+ ],
89
+ };
90
+ AdvancedAnimation.args = {
91
+ initialZoom: null,
92
+ mapId: 5,
93
+ useLeaflet: false,
94
+ zoomLevel: 2,
95
+ height: 600,
96
+ width: 1000,
97
+ lat: 21.096,
98
+ long: -31.690,
99
+ advancedAnimation,
100
+ };