From my perspective the solution was the use of Google Maps, but we could hardly put our customer list on Google Maps. The solution was two-fold. The first was to create a custom application in Salesforce.com that contained all of the information about the products sold to customers. The second portion was to extend this information to Google Maps in a controlled manner using different criteria to control the information displayed.
This blog article is not going into the building of the custom application in Salesforce.com, but the extension of the data to Google Maps. I know there are many articles and data about Salesforce.com and Google Maps already published, but I believe this one is just a little bit different. It is different because one challenge became immediately apparent. In many cases the customer owns several products, and if multiple products are at the same location they are stacked and only one is visible on Google Maps. For those cases I had to merge the information together and display the course information as a single location.
As with any software development project, there is always another way to write the code or implement the design. Sometimes better, sometimes just different. If you see something you do not understand or have an alternate suggestion please speak your mind in a respectful was so all readers can learn from your feedback. For the full code use this GitHub Link
- Custom Controller
- JavaScript Libraries Required
- Google Maps Initilization Fundamentals
- Querying the Salesforce Custom Controller
- Creating an Array of the Salesforce Results
- Determine if there are Multiple Records for a Single Location
- Writing the Event Handlers
- Creating an InfoBox to Provide the Data on mouseOver Event Listeners
- Adding Custom Graphics to the Google Maps Page
Custom Controller
The custom controller is a very basic piece of code with a single simple query. In this case I am just looking for a status of 'open', and for a value in the latitude field on the associated Account record. The code is old enough where I was able to add the test code to the same class, which I think is fine for such a small class file.
custom class customMapController { @RemoteAction custom static ListJavaScript Libraries RequiredgetOpenCourses(){ return [SELECT Id, Name, Account__r.Name, Account__r.Location__Latitude__s, Account__r.Location__Longitude__s, Account__r.ShippingStreet, Account__r.ShippingCity, Account__r.ShippingState, Account__r.ShippingPostalCode, Account__r.ShippingCountry, Account__r.Website, Model__c, Year_Built__c, Status__c FROM Custom_Object__c WHERE Status__c='Open' and Account__r.Location__Latitude__s != null]; } @isTest private static void testGetOpenCourses(){ List control = new List (); Account account = new Account(Name='Customer One', Location__Latitude__s=36.3809047); insert account; control.add(new Custom_Object__c(Name='Course1', Status__c='Open', Account__c = account.id)); control.add(new Custom_Object__c(Name='Course2', Status__c='Open', Account__c = account.id)); insert control; List actual = customMapController.getOpenCourses(); System.assert(actual.size()==2); } }
For this project I decided to reference all libraries from the Google CDN instead of using the Salesforce Static Resource. I believe each can be appropriate and do not want to diverge off into the pros and cons o both. Also I included the style tag in the header. Normally I would create and reference a .css file, but with the small amount I decided to simply add it to the HTML header. To perfect it to Marekting satisfaction I used a different Google font to display the product information.
<apex:page controller="customController" showHeader="false" standardStylesheets="false"> <meta name="viewport" content="initial-scale=1.0, user-scalable=no" /> <link href='https://fonts.googleapis.com/css?family=Tenor+Sans|Open+Sans+Condensed:300' rel='stylesheet' type='text/css' /> <style type="text/css"> html { height: 100% } body { height: 100%; margin: 0; padding: 0;} #map_canvas { height: 100%; width:100%; } .info { font:12px optima, arial,sans-serif; border-style:solid; border-color:#faf4cb; border-spacing:20px 20px; border-radius:10px; background-color:#faf4cb; line-height: 1.5; letter-spacing:1px; } </style> <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script> <script type="text/javascript" src="https://google-maps-utility-library-v3.googlecode.com/svn/tags/infobox/1.1.9/src/infobox_packed.js"></script>Google Map Initialization
As the jQuery .ready() event is fired there are a few initialization steps that occur with the Google Map. This is standard for all Google Maps created and nothing special for this code example. However you should take note of the global variables about the .ready() event as these were required in the effective use of the InfoBox discussed later in this article.
var map; var ib = new InfoBox(); //initialize to create the base map $(document).ready(function() { function initialize() { var courseList=[]; var processedCourses=[]; var myLatLng = new google.maps.LatLng(40.707123, -99.033065); //setting map options var mapOptions = { zoom: 4, center: myLatLng, mapTypeId: google.maps.MapTypeId.ROADMAP }; //adding the map to the div tag map = new google.maps.Map(document.getElementById('map_canvas'), mapOptions);Querying the Salesforce Custom Controller
I created the custom controller to move the details to the backend. I probably could of written the query directly on the Visualforce page, but chose not to.
var address = customController.getOpenCourses(function(records, e){ //go through reach record returned and create a list of course records in which further action such as merging records is taken in subsequent steps.
Creating an Array of the Saleforce Results
Putting the results returned from Salesforce into a list of arrays for easier management in the next steps.
$.each(records,function(index, course){ //each record in the courseList uses the longtitude and latitude as the key for the record. This key will be used to search for duplicate records later. courseList['key'] = course.Account__r.Location__Latitude__s + ':' + course.Account__r.Location__Longitude__s; //add an array of properties for each record to the course list. var c = {}; c['key']=course.Account__r.Location__Latitude__s + ':' + course.Account__r.Location__Longitude__s; c['lat']=course.Account__r.Location__Latitude__s; c['lon']=course.Account__r.Location__Longitude__s; c['customer']=course.Account__r.Name; c['model']=course.Model__c; c['city']=course.Account__r.ShippingCity; c['state']=course.Account__r.ShippingState; c['country']=course.Account__r.ShippingCountry; c['website']=course.Account__r.Website; courseList.push(c); }); //ends .each //now I am going to go through the first list and create a 2nd list merging any duplicates using the longitude and latitude as the location key $.each(courseList, function(key, value) {
Determine if there are Multiple Records for a Single Location
Iterate through the list of records and process each record as a Google Map Marker once. If the location is a
duplicate using the coordinates as the key, merge the information into the first record added as a Marker. The purpose of thee processedCourses list is to determine whether than location has
already been added preventing duplicates. I am sure there are a dozen ways to dice this cat, and this is the way I chose. I encourage you to create your own solutions that might be closer to meeting
your needs if this approach does not work for you.
if( $.inArray(value['key'], processedCourses) == -1 ) { //return all of the course records with the same key. The value of results (one or more records is passed to the event listeners where the information is merged into a single Google Map marker. var result = $.grep(courseList, function(v,i) { return v['key'] === value.key; }); // Create the marker var marker = new google.maps.Marker({ position: new google.maps.LatLng(result[0]['lat'], result[0]['lon']), map: map, title:"", url:"" }); Calls the event handlers functions for each of the marker objects. See this code further along in the article. setMouseOver(result, marker); //create the mouseover event listener setMouseOut(marker); //create the mouseout event listener setMouseClick(marker); processedCourses.push(value.key); }//end if processedCourses == -1 (meaning we have already processed this record) });//end $.each courseList }); //end getOpenCourses call to custom controller
Writing the Event Handlers
The evaluation of the list for duplicates is above, but the actual merging of the data into a single marker occurs in the setMouseOver function. The information from the Salesforce location(s) is formatted to to include trademarks for some, but not all of product models and slightly different display for international locations. The final string is passedto the actual InfoBox that pops up on the Google Maps Marker mouseOver event.
function setMouseOver(result, marker){ var model; var location; var weburl; Loop through the records in the results. The first record is treated different than the rest because some of the information is the same on all records. The only thing different on the records beyond the first the course model. for(var i = 0; i< result.length; i++) { if(i==0){ customer = result[i]['customer']; city = result[i]['city']; state = result[i]['state']; country = result[i]['country']; web = result[i]['website']; if( result[i]['model'].indexOf("Special Model")>= 0){ model = result[i]['model'].replace("Special Model", "Special Model®"); //adding the trademark symbol for certain models }else{ model = result[i]['model']; } }else{ this is not the first record in the results passed into setMouseOver function. We already have the customer information so all we are going to
do is collect the model information and append it to the model information we already have for this customer. This is how the merge occurs into a single marker. if( result[i]['model'].indexOf("Special Model")>= 0){ model = model + ', ' + result[i]['model'].replace("Special Model", "Special Model®"); }else{ model = model + ', ' + result[i]['model']; } }//end if i==0 } //end for results As the locations are international I want to format the address a little bit based on the country. I realize this is incomplete at best, but works enough until I find or write a better solution. city and state if US or CA. For rest the of global city and country if((country !='US')&&(country !='USA')&&(country !='United States') &&(country !='CA')&&(country !='Canada')&&(country !=null)){ location = city + ', ' + country; }else{ location = city + ', ' + state; } if the account record contains a url then create the site name (customer) as a hyperlink. if(web==null){ weburl = '<b>Site: </b>'+ customer + ''; } else { weburl = '<b>Site: </b><a href="javascript:openPage('%20+" web="">' + customer+ '</a>'; } now that we have all of this information it is time to use the infoBox and format the display when a mouseOver event occurs passing in the marker object and using the variable instantiated in this function Creating an InfoBox to Provide the Data on mouseOver Event Listeners
With the text string created for the marker 'boxText' a mouseOver event is created which used an InfoBox to display the text generated for display and a link to the customer website if available in Salesforce. google.maps.event.addListener(marker, "mouseover", function(event) { var boxText = document.createElement("div"); not that I would recommend inline styles, but had issues trying to point to a class so this was the prudent solution. With time I will go back and rehash when I refactor this code. boxText.style.cssText = "font:12px optima;border-style:solid; border-color:#faf4cb; border-spacing:20px 20px; border-padding:20px; border-radius:10px; background-color:#faf4cb;"; boxText.innerHTML ='<div class="info">'+ weburl + '<b>Location: </b>'+ location + '<br />'+'<b>Course(s): </b>' + model +'<br />' +'</div>'; var myOptions = { content: boxText, disableAutoPan: false, maxWidth: 0, pixelOffset: new google.maps.Size(-140, 0), zIndex: null, boxStyle: {opacity:1 ,width: "280px"}, closeBoxMargin: "10px 2px 2px 2px", closeBoxURL: "", infoBoxClearance: new google.maps.Size(1, 1), isHidden: false, pane: "floatPane", enableEventPropagation: false }; Create the InfoBox using the options set above. The text formatted about from the results passed in is displayed in the InfoBox ib = new InfoBox(myOptions); if (ib) {ib.open(map, this);} }); //close mouseover listener }//close function setMouseOver. Go to the next result in the list of courses queried from Salesforce. This course is surprisingly quick, but performance would be an issue if you have several thousand locations. Currently the list is use is about 300 records.
In the code about where we are looping through all of the customer courses you will notice there are calls to three functions at the end of the for each loop. The second function called is setMouseOut. What happens here is the InfoBox stays open for 1.6 seconds so the user has an opportunity to click on the website URL in the InfoBox after the mouse leaves the marker. This was a little tricky to understand when I first wrote the code. In my solution I moved the variable ib to a global scope at the beginning of the code.
function setMouseOut(marker){ google.maps.event.addListener(marker, "mouseout", function(event) { //create ib2 otherwise scrolling across multiple markers causes loss of focus on infobox object var ib2 = new InfoBox(); ib2 = ib; setTimeout(function(){ ib2.close(); },1600); }); //close mouseout listener }//close function setMouseOut
The setMouseEventClick function is used to zoom into the area where the marker was clicked. While the user could click on the Google Map to zoom in this is a convenient way to take a closer look at the location of a marker. Keep in mind this is different than the click of the website URL on the InfoBox
function setMouseClick(marker){ google.maps.event.addListener(marker, "click", function(event) { map.setZoom(9); map.setCenter(marker.getPosition()); });//close click listener }//close function setMouseClickAdding custom Graphics to the Google Maps Page
Adding our own or company logos and possible styling personalizes the page making it clear what the locations represent. With a little additional work you can control the location of the page where the logo occurs and add a legend(as a graphic) for for further explanation in the case of different color Markers.
function addLogoControl(controlDiv) { controlDiv.style.padding = '5px'; var logo = document.createElement('IMG'); logo.src = "{!URLFOR($Resource.Logo)}" logo.style.cursor = 'pointer'; controlDiv.appendChild(logo); google.maps.event.addDomListener(logo, 'click', function() { window.location = 'http://companywebsite.com'; }); }
function openPage(url){ window.open(url,'_blank'); }
The last section of code on the visualforce page is the basic html body that contains the map and was assigned at the beginning of the jQuery ready event. This is pretty standard for Google Maps pages.
<body> <div id="map_canvas"></div> </body> </apex:page>
As a reminder this code is all contained within a Salesforce visualforce page using a remote Javascript call to a custom controller for the locations and data to be displayed. Displaying the locations is fairly easy. I hope this example helps you understand ways to manipulate the list returned (in this case merge of records in the same location). and the use of the InfoBox to display the information. Personally I think the InfoBox is great and looks so much better than the other standard options on displaying Google Marker information.