Let’s build a Map Application using Leaflet and Lightning Components

Salesforce’s Lightning component system is quite a robust framework where you can build full pledged single-page applications in a heartbeat. I especially like it’s built-in SLDS (styles), so all you have to really think about is the logic of your application. In this walk trough, we’re building a real life map application with Lightning.

We’re using Google Places for our city lookup, as well as Leaflet for our map and finally Chatter for our chat component.

The goal is to have pins on the map – which is on the right side of our page. These pins are also shown in a list format on the left hand side of the page. Once a pin is clicked, the list on the left hand side is replaced by the pin details, along with the ability to chat about that specific pin directly below. When the “Close” button is clicked, the list re-appears, as well as the map zooms to its original view.


The same behavior is achieved when the pin on the map is clicked.


Salesforce Map

We also have a custom form, that allows us to add a pin right in the same page. The form has an auto suggest field for the cities.

Note that this is going to be a high level tutorial. You should be well versed in Lightning Components and JavaScript to follow along. A working knowledge of Salesforce is also needed.

Read to get started? Let’s begin.

Setting things Up

Before we can actually start coding, let’s back up and think about what we need. We will need a map software.  We need to store the locations in a custom object in Salesforce. And we need an Apex class to fetch these records for us. Seems simple enough? Let’s continue.


Static Resources

We’re using Leaflet – an open source map software which allows us to build a map on the page. All we need to do is pass it an array of locations (Longitude + Latitude) and Leaflet will populate magically. We also need LeafletMarkerCluster, an add-on to Leaflet, which allows us to bundle our pins together. This prevents the ugly grouping of many pins together.

So add these two zip files as static resources to Salesforce:

Static Resources

Custom Object

Each pin on the map is a record from a Custom Object in Salesforce. Let’s call ours “Pin”. Go ahead and build that custom object with the fields shown below.


custom object
You also might need to enter a few records. Since we’re dealing with coordinates, you will need to Google maps and grab this information. Don’t worry – this is just for the prototype. We are building our own custom form – that will take care of all this information for us.

The Apex Class

An Apex Class can behave sort of like a hash table that can hold our functions. This is how we’re going to interact with data from our Lightning components. Go ahead and create a class called “Pin”. Let’s add a method in there that grabs all of our Pins as well:

public with sharing class Pin {
    public static List<Pin__c> getPins(){
        String sql = 'select Name, City__c, Lat__c, Long__c, Content__c, 
        Country__c, Region__c, Continent__c from Pin__c ORDER BY CreatedDate  
        DESC';
        List<Pin__c> PinList = Database.query(sql);
        return PinList;
    }
}

Above is a class that has one static method “getPins“. This means that we don’t have to instantiate the class to use the method. The @auraEnabled declaration also make it callable directly from our Lightning components.


Component

Once that’s done, go ahead and create a Lightning component – let’s call ours “Pin“. Open pin.cmp and add the code below:


<aura:component implements="flexipage:availableForAllPageTypes"  access="global" controller="Pin">
    <ltng:require styles="{!$Resource.leaflet + '/leaflet.css'}" />
    <ltng:require styles="{!$Resource.leafletMarkerCluster + '/leafletMarerClusterDefault.css'}" />
    <ltng:require styles="{!$Resource.leafletMarkerCluster + '/leafletMarerCluster.css'}" />
    <ltng:require scripts="{!join(',',$Resource.leaflet + '/leaflet.js', $Resource.leafletMarkerCluster + '/leafletMarerCluster.js')}" afterScriptsLoaded="{!c.jsLoaded}" />
</aura:component>

You will see that all we’re doing is setting our static resources up in our component file. We’re also doing controller=”Pin” in our component declaration. This means that we’re going to create an Apex class called “Pin”. We’ll get to that later.

Also note we have afterScriptsLoaded=”{!c.jsLoaded}” in our lightning scripts tag.  This is because we want the function jsLoaded to run as soon as our scripts are loaded. We don’t have this function yet – so loading the page will cause an error. We’ll get to this function soon.

Let’s continue building the markup.

Still in our .cmp file, after our scripts and styles – add our markup below :

<aura:attribute name="pins" type="List" default="[]"/>
<aura:attribute name="singlePin" type="Object" default="{}"/>
<div class="slds-grid">
        <div class="leftcol-wrap slds-col slds-size_1-of-3">
            <!--THIS IS THE LEFT HAND COLUMN, WHERE THE LOCATIONS WILL GO-->
            <!--THIS IS ALSO WHERE THE SINGLE LOCATION DETAIL WILL GO-->
        </div><!--leftcol-wrap-->
        <div class="map-wrap slds-col slds-size_2-of-3">
            <div class="map" id="map" aura:id="map"></div>
        </div><!--end map-wrap-->
</div> <!--end slds-grid-->

The “aura:attribute” named “pins” is simply a holder for the pins we’re grabbing from the controller.

More on this later.

The “aura:attribute” named “singlePin” is also a holder – for a pin that’s in focus. Again, more on this later.

This sets up our page. We’re splitting the page in two, one column is smaller (size_1-of-3) and the other bigger (size_2-of-3). We’re using built in Salesforce classes, with the prefix “slds”, stands for Salesforce Lightning Design System. In case you’re not familiar with it – it’s sort of like Bootstrap and React fused into a single framework.

The Map

So we’re ready to load the page and initialize our Leaflet map. We have our temp data in our custom object, we should have our component – along with the JavaScript files in place. Let’s write the jsLoaded() function (this is called when our scripts are done loading):

jsLoaded: function(component, event, helper) {
	var mapOptions = {
		zoomControl: true,
		zoomAnimation:false, //fixes the error when zooming in...
		markerZoomAnimation:true
	}
        var map = L.map('map', mapOptions)
        map.setView([48.85661400000001, 2.3522219], 2);
        map.scrollWheelZoom.disable();
        helper.map = map;
        L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', {attribution: 'Location'}).addTo(map);
        helper.callServer(component,'c.getPins', function(response){
        	component.set('v.pins',response);
        	helper.buildMap(component, event, helper, response);
        },{});
	}

Okay, a lot going on here. We’re setting some map default options (for more info on Leaflet, see their documentation).  We also have a very important helper function called “.callServer()“. This function is responsible for making the call to our Apex class, and returns our data. Note that I talk about callServer in a previous post. Simply add this function in your PinHelper.js file and that should be good to go.
Inside our callback we’re setting the v.pins in our .cmp file – which is an array of objects that came back from our server.

Right after we have a function called .buildMap() – which is a pretty big function and discussed in detail below.

The Map

So our pins our fetched from the server. I don’t know if you remember, that we are using a Leaflet add-on called LeafletMarkerCluster – which is what does the clustering of the pins – that are in the same proximity. What this does is – it creates a nice visual – the number of pins in a specific grouping, that you can click – and it will zoom in further to show the pins.

It also has a nice “spider” effect – for the clusters – which is really cool:

Cluster
So what we need is a way to group our pins. Notice our custom object earlier – we have a field called “Region”. A region is similar to a “State” in the United States. So every pin that share the same region – will be clustered. Make sure your test data have pins that have the same region, and some that don’t.

Don’t worry – we don’t have to figure out what region our pins need to be – remember we’re building our own form.

The .buildMap() function

Now let’s dig in to the actual code that adds and groups the pins to the map. Open PinHelper.js and add the code below:

map : {},
mapLayers : {},
markerGroups : [],
markerList : [],
buildMap : function(component, event, helper, response){
        var map = helper.map;
        function onMarkerClick(e){
            //TODO: ADD LOGIC WHEN MARKER IS CLICKED
        }
        function onlyUnique(value, index, self) {
            return self.indexOf(value) === index;
        }
        function popupHtml(pinName){
            return '<strong>' + pinName + '</strong>';
        }
        var markerGroups = helper.markerGroups;
        for(var y=0; y<markerGroups.length;y++){
            markerGroups[y].clearLayers();
        }
        var groups = [];
        for(var i=0; i<response.length;i++){
           groups.push(response[i].Region__c);
        }
        var uniqueGroups = groups.filter(onlyUnique);
        var markerOpts = {
            draggable : false
        }
        var markerList = [];
        for(var x=0; x<uniqueGroups.length;x++){
            var markers;
            markers = L.markerClusterGroup();
            helper.markerGroups.push(markers);
            for(var i=0; i<response.length;i++){
                if(response[i].Region__c == uniqueGroups[x]){
                    markerOpts.alt = response[i].Id; //marker options
                    markerOpts.icon = L.icon({iconUrl : 'YOURICONIMAGE.png'});
                    var mark = L.marker([response[i].Lat__c,response[i].Long__c],markerOpts).on('click', onMarkerClick);
                    var popup = mark.bindPopup(popupHtml(response[i].Name));
                    popup.on('popupclose',function(){
                        helper.closeSinglePin(component, event, helper);
                    })
                    markers.addLayer(mark).addTo(map);
                    markerList[response[i].Id]=mark;
                }
            }
        }
        helper.markerList = markerList;
    },

First, let’s set some variables in our helper (lines 1-4). These will act as a container as we pass them around from our controller to helper and vice versa.

Then the actual buildMap() function. We have gone through our pins and determined the unique regions and stuffed them into helper.markerGroups. Now starting in line 39, we loop through each of pin and add them to the map – according to their respective groups (region). Passing along the necessary options for each pin.

Your map should now load with the clusters and the pins.

The List of Pins (Left Side)

On the left side of the application – we show all of the pins – sorted by latest entry. Remember that we set our pins as a component attribute in our jsLoaded() function? Now all we need to do is loop through this in our component. But first, we have to check if there’s a singlePin – why you ask? This is to determine if we clicked on a marker on the map OR we clicked on any of the item in our list (which we’re still building).

We do this by using the aura:if directive.

The code below goes into the left hand section.

<aura:if isTrue="{!v.singlePin.Id}">
	<div class="singlePin" aura:id="singlePin">
		<lightning:button class="closeSinglePin" onclick="{!c.closeSinglePin}">Close</lightning:button>
		 <h3>{!v.singlePin.Name}</h3>
		 <div class="singleMeta">
			<span>{!v.singlePin.Region__c}, &nbsp;</span>
			<span>{!v.singlePin.Continent__c}</span>
		 </div>
		 <div class="singleContent">
			 <aura:unescapedHtml value="{!v.singlePin.Content__c}" />
		 </div>
		 <forceChatter:publisher context="RECORD" recordId="{!v.singlePin.Id}" />
		 <forceChatter:feed type="Record" subjectId="{!v.singlePin.Id}" />
	</div>
	<aura:set attribute="else">
		<h3 class="pinstitle">Locations</h3>
		<aura:iteration items="{!v.pins}" var="pin">
		<lightning:button class="pinListItem" name="{!pin.Id}" onclick="{!c.markerClick}" >
			<div class="pin-name">{!pin.Name}</div>
			<div class="pin-meta">
				<span class="pin-region">{!pin.Region__c}</span>&nbsp;
				<span class="pin-country">{!pin.Country__c}</span>
			</div>
		</lightning:button>
		</aura:iteration>
	</aura:set>
</aura:if>

Let’s look at the singlePin scenario first – which is when an item is clicked. We are simply displaying some data from the singlePin – along with a button to close it (this .closeSinglePin() is not built yet). Then notice the use of  forceChatter:publisher and forceChatter:feed. These components are built in to Lightning – all we need to do is pass it a Id – and voila!

This will display our single pin nicely – along with a Chatter component right below it.


Locations with Chatter

For the list, you see how we wrapped each pin as a lightning:button? That’s because we can click each item and something will happen. We have a c.markerClick handler on each item that we haven’t built yet.
The rest is simply markup of the pin details. Let’s create that handler.

markerClick : function(component, event, helper){
	var markerId = event.getSource().get("v.name");
	var marker = helper.markerList[markerId];
	var markerGroupId = marker['__parent']['_group']['_leaflet_id'];
	var parentChildCount = marker['__parent']['_childCount'];
	if(parentChildCount > 1){
		 for(var i=0; i<helper.markerGroups.length; i++){
			if(helper.markerGroups[i]._leaflet_id == markerGroupId){
				helper.markerGroups[i].zoomToShowLayer(marker, function () {
					console.log('zoomed to layer');
					marker.fire('click')
					marker.openPopup();
				});
			}
		 }
	}else{
		marker.fire('click')
		marker.openPopup();
	}
}

Remember, each item in our list – when clicked, will have the same behavior as clicking the pin (marker) on the map. So the above code is simply a trigger (using marker.fire()). But the check before that is to determine – if the marker is inside a cluster. If it is – we zoom to the layer first (using .zoomToShowLayer()), then fire.

Also, remember that we don’t have any behavior yet in when we click our pins (we have it as a TODO in our .buildMap() function above). Let’s go back and create that now.

function onMarkerClick(e){
	component.set('v.singlePin',{});  //resets to {}
	var id = e.target.options.alt;
	var latLong = e.target.getLatLng();
	if(e.target['_preSpiderfyLatlng']){
		latLong = e.target['_preSpiderfyLatlng']
	}
	map.setView(latLong);
	var pins = component.get('v.pins');
	for(var x=0; x<pins.length;x++){
		if(pins[x].Id == id){
			component.set('v.singlePin',pins[x]);
		}
	}
}

The handler above reset’s the singlePin to an empty object. Then we get the lat and long from the marker (we get it from “e” – or the event). We set the view on the map according to the coordinates.  Then finally – we loop through our pins – and set the v.singlePin into the one clicked.

Lastly, the .closeSinglePin() function:

closeSinglePin : function(component, event, helper){
        var map = helper.map;
        var defaultChatterFeed = component.find('defaultChatterFeed');
        var singlePin = component.find('singlePin');
        $A.util.removeClass(defaultChatterFeed, 'slds-hide');
        $A.util.addClass(singlePin, 'slds-hide');
        component.set('v.singlePin',{});
	map.setView([48.85661400000001, 2.3522219], 2);
        map.closePopup();
    },

The above simply hides our Single Pin and Chatter, resets the singlePin variable, then resets the map.

The Form

Finally, we come to the data entry section – where it will make it easy for us to add pins to our map. This form will have the necessary fields for our custom object – but most importantly, the auto-suggest field – that will auto populate the longitude, latitude, region etc.

City autocomplete


I wrote a tutorial about this Auto-suggest field – using Google Places API, so I’m not going to get into that code. I’m simply going to go through the rest of the logic – that makes our form.

First off, we’re showing our form in a modal. Still in the same component, let’s add this to the lower section of our .cmp file:

<lightning:button name="openNewPin" onclick="{!c.openNewPin}">Add New</lightning:button>
<aura:attribute name="newPin" type="Pin__c" default="{}"/>
<aura:attribute name="predictions" type="List" default="[]"/>
<aura:attribute name="validationErrors" type="List" default="[]"/>
<div role="dialog" tabindex="-1" aura:id="newPinModal" class="slds-modal">
	<div class="slds-modal__container">
		<div class="slds-modal__header">
			<button class="slds-button slds-modal__close slds-button--icon-inverse" title="Close" onclick="{!c.closenewPin}"><span class="slds-assistive-text">Close</span>
			</button>
			<h2 id="header43" class="slds-text-heading--medium">Add New Pin</h2>
		</div>
		<div class="slds-modal__content slds-p-around--medium">
			<div>
				<aura:if isTrue="{!v.validationErrors.length > 0}">
				<ul class="validationErrorList">
				<aura:iteration items="{!v.validationErrors}" var="validationError">
					<li>{!validationError}</li>
				</aura:iteration>
				</ul>
				</aura:if>
				<lightning:input label="Name" value="{!v.newPin.Name}"/>
				<label style="margin:12px 0 3px; display:block;">Content</label>
				<lightning:inputRichText value="{!v.newPin.Content__c}" />
				<div style="position:relative;">
				<lightning:input label="City"
					value="{!v.newPin.City__c}"
					onchange="{!c.getCities}" />
					<aura:if isTrue="{!v.predictions.length > 0}">
						<ul class="city_predictions">
							<aura:iteration items="{!v.predictions}" var="prediction">
							<li class="slds-listbox__item">
								<a onclick="{!c.getCityDetails}" data-placeid="{!prediction.place_id}">{!prediction.description}</a>
							</li>
							</aura:iteration>
						</ul>
					</aura:if>
				<lightning:input value="{!v.newPin.Lat__c}" class="slds-hide" />
				<lightning:input value="{!v.newPin.Long__c}" class="slds-hide"/>
				<lightning:input value="{!v.newPin.Region__c}" class="slds-hide"/>
				<lightning:input value="{!v.newPin.Country__c}"  class="slds-hide"/>
				</div>
			</div>
		</div>
		<div class="slds-modal__footer">
			<lightning:button onclick="{!c.saveRecord}">Submit</lightning:button>
		</div>
	</div>
</div>
<!--end new pin-->
<div class="slds-backdrop " aura:id="Modalbackdrop"></div>
<lightning:notificationsLibrary aura:id="notifLib"/><!--toast message-->

The lightning:button in the beginning, is what opens up the modal. It’s up to you how you want to show this button, and who to show it to.

The next three lines you’ll notice that we’re setting some new attributes. Again, these act as a containers for the variables we’re using in our component.

The SLDS dialog box contain a close button, a header, a footer  and a backdrop. The close button triggers a function closenewPin() when clicked. We’ll get to this later. Then we have a validation error list, then the form fields.

The “City” has an onchange handler called getCities() – which grabs the predictions from our Apex class. Again this was in a previous tutorial and find out how it’s done there.  The lightning:input fields that have a class “slds-hide” – are hidden fields. These fields are automatically filled – and will require no user interaction.

Finally, the lightning:button that takes care of saving the record.

Oh, we also have lightning:notificationsLibrary at the very end – which is Salesforce’s toast notification. We use this for messaging when successful.

Saving the Record

First let’s take care of the entering our record. Open the controller and add the method “saveRecord” below:

saveRecord : function(component, event, helper){
	var newPin = component.get('v.newPin');
	var str = JSON.stringify(newPin);
	//validation
	var errors = [];
	if(!newPin['Name']){
		errors.push('Name cannot be empty');
	}
	if(!newPin['City__c']){
		errors.push('City cannot be empty');
	}
	component.set('v.validationErrors', errors);
	if(errors.length > 0){
		return false;
	}
	helper.callServer(component,"c.savePin",function(data){
		helper.callServer(component,'c.getPins', function(response){
			component.set('v.Pins',response);
			component.set('v.newPin',{});
			helper.buildMap(component, event, helper, response);
			helper.closeModal(component, 'newPinModal');
			helper.showToast(component, 'success', 'A Pin has been added.');
		},{});
	},{"strsRecord" : str});
},

This function gets called as soon as the user clicks the “Save” button. First, we do some validation – and see if our required fields are filled in. If not, we set the validationErrors array up and exits.

Otherwise, we do a .callServer() twice. First to save the pin – and inside it’s callback, we do another .callServer() to get all of the pins. This is so that once we add a new pin – it grabs the new set of pins – so we can see it right away on the map. Remember I discussed about the .callServer() function in “Call Apex from Lightning components“.

This is the part that actually does the saving to the database. Add this to your Apex class – as another method:

@AuraEnabled
public static Pin__c savePin(String strsRecord){
	Pin__c strsRecord2 = (Pin__c)JSON.deserialize(strsRecord,Pin__c.class);
	try{
		if(strsRecord2 != null){
			insert strsRecord2;
		}
	}catch (Exception ex){
	}
	return strsRecord2;
}

Finally, we call .buildMap(), .closeModal() and .showToast(). You get what those functions are doing. Let’s add what’s missing in our helper. Open PinHelper.js and add the code below:

closeModal : function(component, modal){
	var cmpTarget = component.find(modal);
	var cmpBack = component.find('Modalbackdrop');
	$A.util.removeClass(cmpBack,'slds-backdrop--open');
	$A.util.removeClass(cmpTarget, 'slds-fade-in-open');
},
openModal : function(component,modal){
	var cmpTarget = component.find(modal);
	var cmpBack = component.find('Modalbackdrop');
	$A.util.addClass(cmpTarget, 'slds-fade-in-open');
	$A.util.addClass(cmpBack, 'slds-backdrop--open');
},
showToast : function(component, variant, msg){
	var toastSettings = {
		"title": variant,
		"message": msg,
		"variant" : variant,
		"mode" : "dismissable"
	}
	component.find('notifLib').showToast(toastSettings);
},

The helper functions above are pretty self-explanatory. For further explanation – especially about the utilities available in the $A object – refer to this question in StackOverflow.

closeNewPin : function(component, event, helper){
		helper.closeModal(component, 'newPinModal');
},
openNewPin : function(component, event, helper){
	helper.openModal(component, 'newPinModal');
},
closeSinglePin : function(component, event, helper){
	helper.closeSinglePin(component, event, helper)
},

Lastly, the above methods are in our helper, but we need to access them using our controller. Simply add the code below to our controller:

closeNewPin : function(component, event, helper){
		helper.closeModal(component, 'newPinModal');
},
openNewPin : function(component, event, helper){
	helper.openModal(component, 'newPinModal');
},
closeSinglePin : function(component, event, helper){
	helper.closeSinglePin(component, event, helper)
},

And after all is in place, our form should now be working:

Form

Conclusion

There you have it. A cool map with events, clusters, a custom form – a good start of a real world application you can build inside Salesforce. Keep in mind these are mostly using Lightning – unless we had to build it ourselves.

Next up should be filtering the results, adding a better Hover callout on the pins, maybe even having the latest chatter in the hover. The possibilities are endless.

25 Comments

  1. Hey really appreciate the post! I am new to JS but have salesforce experience. I get lost at JSLoaded function. Where would this be in the scheme of things? This is part of the JS application not in salesforce correct?

    Reply
    • jsloaded() is a function that is part of the lightning component that we’re building above. It gets called when all the external scripts are loaded (afterScriptsLoaded) – when using ltng:require. These are all part of the lightning application inside Salesforce.

      Reply
  2. Can you explain how you added the LeafletMarkerCluster as static resource? When i tried, it says it cannot find the .js file.

    Reply
  3. Thanks for an excellent tutorial. I will be leveraging your code to create pins on a map from underlying sobjects that already have Lat/Lngs so I will not be using your form.

    Reply
    • The helper.tileLayer is simply holds the value: ‘https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}’. You can actually pass this string directly to the L.tileLayer method. I’ve updated the tutorial. Sorry for the confusion.

      Reply
  4. I finally got this to work. The missing piece was the map style in the component. Can you please share yours? This is an awesome configuration esp. the clustering and spidering.

    Reply
  5. Hi,
    Thanks for this great tutorial, still ironing out some issues but I think you shared the “singlePin” methods in the controller twice in the helper section as well. Do you mind sharing the those methods in the helper class that the controller is referring to?
    Thanks!

    Reply
  6. How do i get map to interact with an Einstein Analytics charts are part of a Einstein Analytics Dashboard?
    Is it possible to embed map into an Einstein Analytics Dashboard ?

    Reply
  7. Hey really appreciate the post!
    Mate, I got a problem with the map component, its shows up if I embed into a Lighting App but when I add it to a record page it’s not showing up.
    Thanks.

    Reply
  8. Hi, thanks for your post. I was wondering how can I display the map into a record page. I’ve try it but does not work.
    Thanks

    Reply
  9. First of all, thanks for this amazing tutorial Michael.
    Im getting this issue when I try to put it on a record page: controller$jsLoaded [Map container is already initialized.]
    Do you know what it could be?
    Many thanks Michael.

    Reply
  10. Good afternoon Michael Soriano, can you help me I’m using leaflet already but I recently encountered an error on android mobile devices.
    When we zoom to see a pin, the map returns to its original state after a few seconds and takes us away from the pin again.
    I hope you can help me please

    Reply

Leave a Reply to Tyler J Cowie Cancel reply