Applications use Google maps for showing routes from point A to B. For one of our clients we needed to show delivery routes on Google maps so that user can select multiple deliveries and then consolidate them as one single delivery. This meant we needed to show around 30 to 500 deliveries on a single map.
Using Google Map polylines
We used polylines to draw individual routes on Google maps.
Polyline is composed of line segments connecting a list of points on the map. The more points we use for drawing a polyline the more detailed the final curve will be. Here's how we added route points to the map.
1// List of latitude and longitude 2let path = points.map(point => [point.lat, point.lng]); 3 4let route_options = { 5 path: path, 6 strokeColor: color, 7 strokeOpacity: 1.0, 8 strokeWeight: mapAttributes.strokeWeight || 3, 9 map: map, // google.maps.Map 10}; 11 12new google.maps.Polyline(route_options);
Here's an example of a polyline on a Google map. We used 422 latitude and longitude points to draw these routes which makes it look more contiguous.
We needed to show 200 deliveries in that map. On an average a delivery contains around 500 route points. This means we need to load 1,00,000 route points. Let's measure and see how much time the whole process takes.
Loading multiple routes on a map
Plotting a single route on a map can be done in less than a second. However as we increase the number of routes to plot, the payload size increases which affects the load time. This is because we've around 500 route points per delivery. If we want to show 500 deliveries on the map then we need to load 500 * 500 = 2,50,000 routes points. Let's benchmark the load time it takes to show deliveries on a map.
No. of deliveries | Load Time | Payload Size | 500 | 8.77s | 12.3MB | 400 | 7.76s | 10.4MB | 300 | 6.68s | 7.9MB | 200 | 5.88s | 5.3MB | 100 | 5.47s | 3.5MB |
The load time is more than 5 seconds which is high. What if we could decrease the payload size and still be able to plot the routes.
Sampling route points for decreased payload size
For each delivery we've around 500 route points. If we drop a few route points in between on a regular interval then we'll be able to decrease the payload size. Latitude and longitude have at least 5 decimal points. We rounded them off to 1 decimal point and then we picked unique values.
1 def route_lat_lng_points 2 return '' unless delivery.route_lat_lng_points 3 4 delivery.route_lat_lng_points. 5 chunk{ |point| [point.first.round(1), point.second.round(1)] }. 6 map(&:first).join(',') 7 end
Now let's check the payload size and the load time.
No. of deliveries | Load Time | Payload Size | 500 | 6.52s | 6.7MB | 400 | 5.97s | 5.5MB | 300 | 5.68s | 4.2MB | 200 | 4.88s | 2.9MB | 100 | 4.07s | 2.0MB |
The payload size decreased by 50 percent. However since we sampled the data the routes are not contiguous anymore. Here's how it looks now.
Note that we sampled route points till single decimal point. Notice that the routes in which we did sampling appears to be jagged instead of contiguous. We can solve this by using a curve fitting method to create a curve from the discrete points we have.
Curve fitting using B-spline function
B-spline or basis spline is a spline function which can be used for creating smooth curves best fitted to a set of control points. Here's an example of a B-spline curve created from a set of control points.
We changed our previous example to use B-spline function to generate latitude and longitude points.
1// List of latitude and longitude 2let lats = points.map(point => point.lat); 3let lngs = points.map(point => point.lng); 4let path = bspline(lats, lngs); 5 6let route_options = { 7 path: path, 8 strokeColor: color, 9 strokeOpacity: 1.0, 10 strokeWeight: mapAttributes.strokeWeight || 3, 11 map: map, // instance of google.maps.Map 12}; 13 14new google.maps.Polyline(route_options);
1function bspline(lats, lngs) { 2 let i, t, ax, ay, bx, by, cx, cy, dx, dy, lat, lng, points; 3 points = []; 4 5 for (i = 2; i < lats.length - 2; i++) { 6 for (t = 0; t < 1; t += 0.2) { 7 ax = (-lats[i - 2] + 3 * lats[i - 1] - 3 * lats[i] + lats[i + 1]) / 6; 8 ay = (-lngs[i - 2] + 3 * lngs[i - 1] - 3 * lngs[i] + lngs[i + 1]) / 6; 9 10 bx = (lats[i - 2] - 2 * lats[i - 1] + lats[i]) / 2; 11 by = (lngs[i - 2] - 2 * lngs[i - 1] + lngs[i]) / 2; 12 13 cx = (-lats[i - 2] + lats[i]) / 2; 14 cy = (-lngs[i - 2] + lngs[i]) / 2; 15 16 dx = (lats[i - 2] + 4 * lats[i - 1] + lats[i]) / 6; 17 dy = (lngs[i - 2] + 4 * lngs[i - 1] + lngs[i]) / 6; 18 19 lat = 20 ax * Math.pow(t + 0.1, 3) + 21 bx * Math.pow(t + 0.1, 2) + 22 cx * (t + 0.1) + 23 dx; 24 25 lng = 26 ay * Math.pow(t + 0.1, 3) + 27 by * Math.pow(t + 0.1, 2) + 28 cy * (t + 0.1) + 29 dy; 30 31 points.push(new google.maps.LatLng(lat, lng)); 32 } 33 } 34 return points; 35}
Source: https://johan.karlsteen.com/2011/07/30/improving-google-maps-polygons-with-b-splines
After the change the plotted routes are much better. Here's how it looks now.
The only downside here is that if we zoom in the map we'll notice that the routes are not exactly overlapping the Google map paths. Otherwise we're able to plot almost same routes with sampled route points. However we still need 6.5 seconds to load 500 deliveries. How do we fix that ?
Loading deliveries in batches
Sometimes users have up to 500 deliveries but they want to change only a few deliveries and then use the application. Right now the way application is setup users have no choice but to wait until all 500 deliveries are loaded and then only they would be able to change a few deliveries. This is not ideal.
We want to show deliveries as soon as they're loaded. We added a polling mechanism that would load batches of 20 deliveries and as soon as a batch is loaded we would plot them on the map. This way user could interact with the loaded deliveries while the remaining deliveries are still being loaded.
1 loadDeliveriesWindow(updatedState = {}, lastPage = 0, currentWindow = 1) { 2 // windowSize: Size of the batch to be loaded 3 // perPage: No of deliveries per page 4 const { perPage, windowSize } = this.state; 5 6 if (currentWindow > perPage / windowSize) { 7 // Streaming deliveries ended 8 this.setState($.extend(updatedState, { windowStreaming: false })); 9 return; 10 } 11 12 if (currentWindow === 1) { 13 // Streaming deliveries started 14 this.setState({ windowStreaming: true }); 15 } 16 17 // Gets delivery data from backend 18 this.fetchDeliveries(currentWindow + (lastPage * windowSize), queryParams).complete(() => { 19 // Plots deliveries on map 20 this.loadDeliveries(); 21 22 // Load the next batch of deliveries 23 setTimeout((() => { 24 this.loadDeliveriesWindow(updatedState, lastPage, currentWindow + 1); 25 }).bind(this, currentWindow, updatedState, lastPage), 100); 26 }); 27 }
Here's a comparison of how the user experience changed.
Notice that loaded deliveries are instantly plotted and user can start interacting with them. While if we load all the deliveries before plotting them the user has to wait for all of them to be loaded. This made the user experience much better if the user loaded more than 100 deliveries.
Serializing route points list without brackets
One more optimization we did was to change how route points are being serialized.
The route points after serialization contained opening and closing square brackets. So let's say the route points are
[[25.57167, -80.421271], [25.676544, -80.388611], [25.820025, -80.386488],...].
After serialization they looked like
[[25.57167,-80.421271], [25.676544,-80.388611], [25.820025,-80.386488],...].
For each route point we've an extra opening and closing square bracket which can be avoided.
We could get rid of the brackets by concatenating the route points array and converting it to a string. After conversion it looked like this.
"25.57167,-80.421271|25.676544,-80.388611|25.820025,-80.386488|..."
At the client side we converted it back to an array. This reduced the payload size by 0.2MB for dense routes.
Note this is a trade off between client side processing and network bandwidth. In modern computers the client side processing will be negligible. For our clients, network bandwidth was a crucial resource so we optimized it for network bandwidth.