Google Maps is great. Laravel is great. Together, they can create awesome things.
For a new project I've been looking at how to implement Google Maps with dynamic markers.
This could lend itself using a number of different methods - in this use case we're dealing with stores that have a catchment are, so that as you move the map, the only markers that are shown are those of stores within whose catchment area the center of the map falls.
It's also worth mentioning that if your needs are less complex than mine (there's other stuff going on behind the scenes that isn't really relevant to this post) then you may just be able to rely on Google Maps' computeDistanceBetween method - but that wouldn't quite suffice for my needs in this case.
First up, let's set up a controller method to display the initial map page.
# app\Http\Controllers\FrontendController
public function map(Request $request) {
}And then, of course, we need to add a route to it in routes/web.php
# routes/web.php
Route::get('/map', 'App\Http\Controllers\FrontendController@map') - >name('map');We're going to use the user's session to store where their map is currently centered on their first visit to the page they won't have any location set on the map, so we'll set a default location, but thereafter they should do, so back in our method we'll retrieve those details and use them (if set), and set our default and store them (if not).
# app\Http\Controllers\FrontendController
public function map(Request $request) {
if(Session::has('map - longitude')) {
$initialLongitude = Session::get('map - longitude');
} else {
$initialLongitude = ' - 3.00910';
Session::put('map - longitude', ' - 3.00910');
}
if(Session::has('map - latitude')) {
$initialLatitude = Session::get('map - latitude');
} else {
$initialLatitude = '53.9185';
Session::put('map - latitude', '53.9185');
}
}Whether it's their first time looking at the map or a return visit, on page load we need to check if there are any stores within whose catchment area the initial location falls.
We're going to be checking for this information when the map location changes as well, so for sake of ease we'll have it in its own function called findStores():
# app\Http\Controllers\FrontendController
use App\Models\Store;
use Illuminate\Database\Eloquent\Collection;
public function findStores($latitude, $longitude) {
$stores = Store::all();
$locations = new Collection();
foreach($stores as $store) {
foreach($store - >locations as $location) {
$distance = $this - >getDistance($location - >latitude, $location - >longitude, $latitude, $longitude);
if($distance < $location - >catchment) {
$locations[] = $location;
}
}
}
return $locations;
}Our stores are found through the relevant Model - each store can have multiple locations and each location has a catchment value which specifies its catchment area in miles, so we loop through each store, then each location for that store, to work out the distance from the center of the map, and if that's less than the catchment area, add it to the $locations Collection, which the function returns as its response at the end.
There is doubtless a more efficient way of doing it (in case you were wondering, we're not loading all the Locations as our starting collection, as some locations are relevant to entities other than stores), but we are not dealing with thousands of stores at this point, so it will do for the time being.
The delights of working out the distance between two locations using longitude and latitude is handled by something called the Haversine formula which (again, for sake of ease) I've farmed out to its own function called getDistance() :
# app\Http\Controllers\FrontendController
public function getDistance(string $storeLat, string $storeLong, string $userLat, string $userLong) {
$earthRadius = 3959; // We're working in miles - the imperial system FTW - but change this to 6371 for measurements in KM
$latFrom = deg2rad($storeLat);
$longFrom = deg2rad($storeLong);
$latTo = deg2rad($userLat);
$longTo = deg2rad($userLong);
$longDelta = $longTo - $longFrom;
$latDelta = $latTo - $latFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) + cos($latFrom) * cos($latTo) * pow(sin($longDelta / 2), 2)));
return round(($angle * $earthRadius),2);
}Back in our map() function, therefore, we now need to get the stores relevant to the initial location of the map - be that set by default or from the user's session - and use them to populate an array of data that we will pass to Google Maps as the relevant information about our initial markers, along with the initial latitude and longitude.
Because we want to populate this array when we first load, and on subsequent changes of the map, we'll have it in its own function too.
# app\Http\Controllers\FrontendController
public function map(Request $request) {
...
$locations = $this - >findStores($initialLatitude, $initialLongitude);
$initialMarkers = array();
return view('map', compact('initialMarkers', 'initialLatitude', 'initialLongitude'));
}
public function generateMarkerData(Collection $locations) {
$markerdata = array();
foreach($locations as $location) {
$store = $location - >store() - >first();
$markerdata[] = [
'position' => [
'lat' => $location - >latitude,
'lng' => $location - >longitude
],
'label' => [ 'color' => 'white', 'text' => ucfirst(substr($store - >name,0,1,)) ],
'title' => $store - >name,
'draggable' => false
];
}
return $markerdata;
}Now we want to turn our attention to the Blade template for the map. I won't put the whole layout here, just the relevant bits, so in your CSS you will want to include a brief snippet :
# resources/views/map.blade.php
You'll also want to be sure that Laravel is outputting a meta tag with your CSRF token, as we'll need that in a bit :
# resources/views/map.blade.php
<meta name="csrf - token" content="9EkPrVzX0kU4OuOy4ZffPRcEHuw0ehrhj7OJSXeb">And then the body of the page just comprises a heading, and the div that the map will be loaded into :
# resources/views/map.blade.php
<h1 class="text - center">Our Google Map</h1>
<div id="map"></div>Sure, it's not going to be pretty, but pretty's not our concern at this stage. The bulk of the work takes place in the footer of the page where the scripts come into play.
First we want to load the Google Maps API (using your own key, which should be stored in the .env file and accessed via a custom configuration). At the same time we will set the map's central location to be whatever the $initialLatitude and $initialLongitude were from our controller, and then call the plotMarkers() function.
# resources/views/map.blade.php
<script src="https://maps.googleapis.com/maps/api/js?key=&callback=initMap" async><s;/script>
<script>
let map, activeInfoWindow, infoWindow, markers = [];
map = new google.maps.Map(document.getElementById("map"), {
center: { lat: {{ $initialLatitude }}, lng: {{ $initialLongitude }} },
zoom: 13
});
plotMarkers(<?php echo json_encode($initialMarkers); ?>);
});
function plotMarkers(mapMarkers) {
for (let index = 0; index < mapMarkers.length; index++) {
const markerIcon = {
url: "{{ url('/images/custompin.svg') }}",
scaledSize: new google.maps.Size(50,50)
};
const markerData = mapMarkers[index];
const marker = new google.maps.Marker({
position: {
lat : parseFloat( markerData.position.lat),
lng : parseFloat( markerData.position.lng)
},
draggable: markerData.draggable,
title : markerData.title,
icon : markerIcon,
map
});
markers.push(marker);
const infowindow = new google.maps.InfoWindow({
content: `${markerData.title}`,
});
marker.addListener("click", (event) => {
if(activeInfoWindow) {
activeInfoWindow.close();
}
infowindow.open({
anchor: marker,
shouldFocus: false,
map
});
activeInfoWindow = infowindow;
});
}
}
I don't propose to go into a huge amount of detail about plotting the markers, but in essence this uses the first letter of the store name as an indicator on top of a custom map pin (loaded from an SVG). If the user clicks on the pin an info window pops up with - just for the purposes of this article - the store name.
Whenever the user moves the map, of course, we want to refresh the markers. To do this we make use of an event listener on the Google Maps API's 'idle' event. In theory this should wait until the user has stopped interacting with the map for a short period of time, but in practice I still needed to debounce it to get it to not fire dozens of times as the map was being moved.
google.maps.event.addListener(map, "idle", function() {
window.setTimeout(() => {
var center = this.getCenter();
var latitude = center.lat();
var longitude = center.lng();
$.ajaxSetup({
headers: {
'X - CSRF - TOKEN': $('meta[name="csrf - token"]').attr('content')
}
});
$.ajax( {
url: '{{ route('map.search')}}',
type: 'POST',
data: {
longitude : longitude,
latitude : latitude,
}
}).then(function (data) {
clearMarkers(null);
console.log(data);
plotMarkers(data);
});
}, 500);
});This gets the current center of the map, extracts the longitude and latitude, and then calls an Ajax function (that's why we needed the CSRF token in the meta tag) back in our controller. The route's called 'map.update' so we need to be sure to add that to our web routes :
# routes/web.php
Route::post('/map', 'App\Http\Controllers\FrontendController@mapsearch') - >name('map.search');And then back in our controller, we've already done the bulk of the hard work, so our mapsearch() function is relatively slimline.
# app/Http/Controllers/FrontendController.php
public function mapsearch(Request $request) {
Session::put('map - longitude', $request - >longitude);
Session::put('map - latitude', $request - >latitude);
$locations = $this - >findStores($request - >latitude, $request - >longitude);
$markers = $this - >generateMarkerData($locations);
return $markers;
}Because we've already got the generateMarkerData() method, this call is nice and easy - it stores the new center of the map in the session, retrieve the relevant stores for the new location, and return them as an array.
Back in our blade template, this part of our Ajax call clears the map data of existing markers and plots our new ones :
}).then(function (data) {
clearMarkers(null);
console.log(data);
plotMarkers(data);
});And that's pretty much it. Thoughts and feedback are welcomed.
If you're tracking how users interact with features like dynamic maps, we covered Google Analytics in more detail in Master Google Analytics: Essential Insights for SMBs.