Previously for work I was required to use Google Maps API to create an interactive map, simply because that is what the company had previously used but for my own personal projects I lean towards Open Source. This past week I decided to dive into Leaflet and see just what I could achieve and how it fairs against Google Maps API.
After reading this article you will be able to create a similar map based application to the below which loads cycling routes from a GeoJSON feature collection and places them on the map , along with a seperate series of bicycle rental points.
WHAT IS LEAFLET?
Leaflet is a lightweight open-source Javascript library used for creating mobile and interactive maps. It is the main alternative to Google Maps API , yet unlike Google Maps it does not include the actual Map tiles - this requires that we use other sources such as Mapbox for our map tiles.Get Started
First off we need to register with Mapbox so we can use their tiles with our App. Head over to the Mapbox signup page and fill out the required form. Once the account is created you can create a dedicated token or use the default one. Make a note of this token for later. The whole process should take under two minutes.Adding basic map
First off we need to add the container for the map. In your HTML body add
<div id="mapid"> </div>
then in the CSS style sheet or Style section of the header add the below, otherwise we wont be able to see the map !
#mapid {
height: 100%;
}
Now we need to load the map into our container and set the starting coordinates . I will use coordinates for Galway City taken from Google Maps in the setView method which takes Latitude and Longitude in the form of an array followed by the zoom level. Then load the map tile from Mapbox but make sure to use your own API token in place of my placeholder.
// Set API token and create map
var token = "{YOUR TOKEN HERE}" ;
var mymap = L.map('mapid').setView([53.27, -9.05], 13);
// Add tile to map
L.tileLayer(`https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token=${token}`, {
attribution: 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox',
maxZoom: 18,
id: 'mapbox/streets-v11',
tileSize: 512,
zoomOffset: -1
}).addTo(mymap);
Loading Route Data from GeoJSON
Once we have our basic map we need to load the features from the GeoJSON source. We can utilize the geoJSON method for this along with onEachFeature. We can set a basic style for the lines and a simple on click pop up.
// Add each feature to the map
geojson = L.geoJSON(geojsonFeature,{
onEachFeature: function(feature, layer) {
//Set initial Styles
layer.setStyle({
weight: 3,
color: '#F2561D',
dashArray: '',
fillOpacity: 0.7
});
//Add the onclick pop up
layer.bindPopup('Name: ' +feature.properties.Cycle_Lane );
}
}).addTo(mymap);
Add Markers
We can add a custom marker for each Bicycle rental point in our bikedata array very easily by first creating a custom icon and then itterating over our object array and creating a marker using the the objects coordinate data.
var bikon = L.icon({
iconUrl: 'bikon.png',
iconSize: [37, 28]
});
/* Add Bike Locations Markers */
bikedata.forEach(function(item){
var popupContent = `<div><h5>${item.name}</h5>
<p<Capacity: <strong>${item.standQuantity}</strong></p>
</div>`
L.marker([item.coordinates.latitude, item.coordinates.longitude],{icon:bikon}).addTo(mymap).bindPopup(popupContent)
})
This gives us a basic map with a the cycling routes and markers, but we can do more, We'll add a few things: distance measures, interactivity and a panning function.
Adding More Logic To Each Feature
For each feature we need to get the total distance of the route, and also get a rough center point so we can use it to later to "Zoom" to.We can measure the distance of each route by using the Haversine formula to get the distance between each point in the line and total each of these distances. The below function which I have used previously will do what we require.
// Haversine function for distance calculating between two coordinates
function getDistance(lat1, lon1, lat2, lon2) {
var R = 6371; // Radius of the earth in kilometers
var dLat = deg2rad(lat2 - lat1); // deg2rad below
var dLon = deg2rad(lon2 - lon1);
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
var d = R * c; // Distance in KM
return d;
}
function deg2rad(deg) {
return deg * (Math.PI / 180)
}
We'll need to call this function from within the onEachFeature to measure the length of the route and also keep a running total of all the lengths. At the same time we will save a rough center point of the route for use in a panning function.
See the altered onEachFeature below. Because each feature is still a Javascript Object we can assign both of these as object properties for access later.
// Add each feature to the map
geojson = L.geoJSON(geojsonFeature,{
onEachFeature: function(feature, layer) {
// bindPopup during creation directly.
var feature_distance = 0 ;
var pointA,pointB,center;
if(feature.geometry.type=="MultiLineString"){
var totalpoints = 0 , currentpoint = 0 ;
// Get total number of points across all lines
for( k = 0 ; k < feature.geometry.coordinates.length; k++){
totalpoints+= feature.geometry.coordinates[k].length
}
// Itterate over each line in multi line string
for( k = 0 ; k < feature.geometry.coordinates.length; k++){
var i = 0
// Itterate over each coordinate and get distance between it and the previous
for(i = 0; i < feature.geometry.coordinates[k].length ; i++){
//Check if this is the "middle" point
if(currentpoint == Math.floor(totalpoints /2 ) ){
center = feature.geometry.coordinates[k][i]
}
if(i==0){
pointA = feature.geometry.coordinates[k][i]
}else{
pointB = feature.geometry.coordinates[k][i]
feature_distance += getDistance(pointA[1],pointA[0], pointB[1],pointB[0] )
}
currentpoint ++ ;
// Now Set A to Current
pointA = feature.geometry.coordinates[k][i]
}
}
}
else{
// Itterate over each coordinate and get distance between it and the previous
for(var i = 0 ; i < feature.geometry.coordinates.length ; i++){
// Get middle point of the Line for our Panning Function
if(i == Math.floor(feature.geometry.coordinates.length /2 ) ){
center = feature.geometry.coordinates[i]
}
if(i==0){
pointA = feature.geometry.coordinates[i]
}else{
pointB = feature.geometry.coordinates[i]
//feature_distance += getDistance(pointA, pointB )
feature_distance += getDistance(pointA[1],pointA[0], pointB[1],pointB[0] )
}
// Now Set A to Current
pointA = feature.geometry.coordinates[i]
}
}
//Set initial Styles
layer.setStyle({
weight: 3,
color: '#F2561D',
dashArray: '',
fillOpacity: 0.7
});
// Add new properties to the feature
feature.properties.Total_Length = (feature_distance).toFixed(3)
feature.properties.CenterPoint = center ;
// Add to running sum of distances
total_distances += feature_distance
//Add the onclick pop up
layer.bindPopup('Name: ' +feature.properties.Cycle_Lane + '<br />Distance: ' + feature.properties.Total_Length +" KM");
}
}).addTo(mymap);
Adding Interactivity
We can add a bit of interactivity by adding a drop down list that highlights the selected route, adds the information to an information box and also pans to it. First alter the HTML and add a div element and a select element with our default option and an onchange event.
<div class="app">
<div id="mapid"></div>
<div class="app-info">
<div id="route_info"></div>
<div id="total-distance-container"></div>
<select onchange="onRouteChange(this)" id ="routes">
<option default >All</option>
</select>
</div>
Now the below onchange event will handle updating the selected style and resetting the unselected routes back to default. It uses the unique OBJECTID property to identify each feature. We can use the PanToArea to move the center point of the map to the selected route.
/* Update the Styles for the selected Route and reset others to the unselected style */
function onRouteChange(route_item) {
var item = route_item.value
geojson.eachLayer(function (layer) {
try {
if(item.toLowerCase() == "all"){
highlightFeature({ "target": layer })
SetInfoWindow('All Routes', total_distances.toFixed(3))
}
else if(layer.feature.properties.OBJECTID == item){
highlightFeature({ "target": layer })
SetInfoWindow(layer.feature.properties.Cycle_Lane, layer.feature.properties.Total_Length)
PanToArea(layer.feature.properties.CenterPoint)
} else {
resetHighlight({ "target": layer })
}
} catch(e) {}
});
};
/* Separated into own function as it is used in multiple places */
function SetInfoWindow(title,distance){
document.getElementById("route_info").innerHTML = `
<div>
<h4>${title}</h4>
<p>${distance} KM</p>
</div>
`
}
/* Functions For Handling Style Changes */
function highlightFeature(e) {
var layer = e.target;
layer.setStyle({
weight: 4,
color: '#9BBF17',
dashArray: '',
fillOpacity: 0.7
});
if (!L.Browser.ie && !L.Browser.opera) {
layer.bringToFront();
}
}
function resetHighlight(e) {
var layer = e.target;
geojson.resetStyle(layer);
layer.setStyle({
weight: 3,
color: '#F2561D',
dashArray: '',
fillOpacity: 0.7
});
}
function PanToArea(coordinates) {
mymap.panTo([coordinates[1],coordinates[0]]);
}
Now back inside the onEachFeature we need to make sure each feature is added to the select element.
// Add this feature as a select option
var option = document.createElement("option");
option.text = feature.properties.Cycle_Lane;
option.value = feature.properties.OBJECTID;
routelist.add(option);
We can now select and highlight different routes with the drop down list. Even a simple addition like this can improve the user experience by adding a little interactivity.
Wrapping Up
This article only skims the surface of the capabilities of Leaflet, and there is plenty more that I haven't touched, so make sure to check out their official tutorials and documentation. The complete example can be found on my Github here.Thanks for reading and make sure to check in next time when I demonstrate how to use the popular Chart JS javascript library ! If you have any suggestions or questions please comment below.
Comments
Post a Comment