I have been working through writing a small app with a lot of help from stackoverflow. The basic premise is simple, and I've seen this sort of functionality all over the web: I am trying to plot a list of locations onto a searchable/pannable google map. The locations are stored on the backend, and the controller feeds these locations to the view. AJAX is involved because I don't want to reload the entire page. Here are the scenarios: a) User searches for a location via zipcode => map loads new location, a search get is sent to server and map loads any markers if there are any within set radius, map sets a default zoom level; b) User pans/zooms around => map stays wherever the user has left it, a search with the viewport bounding box is sent to the server and the results are mapped. The map will default to Seattle on initial load and the first thing it tries is to geolocate the user...
Using the gmaps4ails wiki and mostly a modified version of the answer from this question: Google Maps for Rails - update markers with AJAX I have gotten very close. It works, actually, just with a hitch. Here is what it looks like:
sightings_controller.rb
def search
if params[:lat]
@ll = [params[:lat].to_f, params[:lng].to_f]
@sightings = Sighting.within(5, origin: @ll).order('created_at DESC')
@remap = true
elsif search_params = params[:zipcode]
geocode = Geokit::Geocoders::GoogleGeocoder.geocode(search_params)
@ll = [geocode.lat, geocode.lng]
@sightings = Sighting.within(5, origin: @ll).order('created_at DESC')
@remap = true
elsif params[:bounds]
boundarray = params[:bounds].split(',')
bounds = [[boundarray[0].to_f, boundarray[1].to_f], [boundarray[2].to_f, boundarray[3].to_f]]
@ll = [params[:center].split(',')[0].to_f, params[:center].split(',')[1].to_f]
@sightings = Sighting.in_bounds(bounds, origin: @ll).order('created_at DESC')
@remap = false
else
search_params = '98101'
geocode = Geokit::Geocoders::GoogleGeocoder.geocode(search_params)
@ll = [geocode.lat, geocode.lng]
@sightings = Sighting.within(5, origin: @ll).order('created_at DESC')
@remap = true
end
@hash = Gmaps4rails.build_markers(@sightings) do |sighting, marker|
marker.lat sighting.latitude
marker.lng sighting.longitude
marker.name sighting.title
marker.infowindow view_context.link_to("sighting", sighting)
end
respond_to do |format|
format.html
format.js
end
end
search.html.haml
= form_tag search_sightings_path, method: "get", id: "zipform", role: "form", remote: true do
= text_field_tag :zipcode, params[:zipcode], size: 5, maxlength: 5, placeholder: "zipcode", id: "zipsearch"
= button_tag "Search", name: "button"
%input{type: "button", value: "Current Location", onclick: "getUserLocation()"}
#locationData
.sightings_map_container
.sightings_map_canvas#sightings_map_canvas
#sightings_container
- content_for :javascript do
%script{src: "//maps.google.com/maps/api/js?v=3.13&sensor=false&libraries=geometry", type: "text/javascript"}
%script{src: "//google-maps-utility-library-v3.googlecode.com/svn/tags/markerclustererplus/2.0.14/src/markerclusterer_packed.js", type: "text/javascript"}
:javascript
function getUserLocation() {
//check if the geolocation object is supported, if so get position
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(setLocation);
}
else {
document.getElementById("locationData").innerHTML = "Sorry - your browser doesn't support geolocation!";
}
}
function setLocation(position) {
//build text string including co-ordinate data passed in parameter
var displayText = "Latitude: " + position.coords.latitude + ", Longitude: " + position.coords.longitude;
//display the string for demonstration
document.getElementById("locationData").innerHTML = displayText;
//submit the lat/lng coordinates of current location
$.get('/sightings/search.js',{lat: position.coords.latitude, lng: position.coords.longitude});
}
// build maps via Gmaps4rails
handler = Gmaps.build('Google');
handler.buildMap({
provider: {
},
internal: {
id: 'sightings_map_canvas'
}
},
function() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(setLocation);
}
var json_array = #{raw @hash.to_json};
var latlng = #{raw @ll};
resetMarkers(handler, json_array);
resetMap(handler, latlng);
// listen for pan/zoom and submit new coordinates
(function gmaps4rails_callback() {
google.maps.event.addListener(handler.getMap(), 'idle', function() {
var bounds = handler.getMap().getBounds().toUrlValue();
var center = handler.getMap().getCenter().toUrlValue();
$.get('/sightings/search.js',{bounds: bounds, center: center, old_hash: #{raw @hash.to_json}});
})
})();
});
search.js.erb
(function() {
var json_array = <%= raw @hash.to_json %>;
if (<%= @remap %>) {
var latlng = <%= raw @ll %>;
resetMarkers(handler, json_array);
resetMap(handler, latlng);
}
else {
resetMarkers(handler, json_array);
}
})();
map.js
(function() {
function createSidebarLi(json) {
return ("<li><a>" + json.name + "</a></li>");
};
function bindLiToMarker($li, marker) {
$li.on('click', function() {
handler.getMap().setZoom(18);
marker.setMap(handler.getMap()); //because clusterer removes map property from marker
google.maps.event.trigger(marker.getServiceObject(), 'click');
})
};
function createSidebar(json_array) {
_.each(json_array, function(json) {
var $li = $( createSidebarLi(json) );
$li.appendTo('#sightings_container');
bindLiToMarker($li, json.marker);
});
};
function clearSidebar() {
$('#sightings_container').empty();
};
function clearZipcode() {
$('#zipform')[0].reset();
};
/* __markers will hold a reference to all markers currently shown
on the map, as GMaps4Rails won't do it for you.
This won't pollute the global window object because we're nested
in a "self-executed" anonymous function */
var __markers;
function resetMarkers(handler, json_array) {
handler.removeMarkers(__markers);
clearSidebar();
clearZipcode();
if (json_array.length > 0) {
__markers = handler.addMarkers(json_array);
_.each(json_array, function(json, index){
json.marker = __markers[index];
});
createSidebar(json_array);
}
};
function resetMap(handler, latlng) {
handler.bounds.extendWith(__markers);
handler.fitMapToBounds();
handler.getMap().setZoom(12);
handler.map.centerOn({
lat: latlng[0],
lng: latlng[1]
});
}
// "Publish" our method on window. You should probably have your own namespace
window.resetMarkers = resetMarkers;
window.resetMap = resetMap;
})();
Here's the problem, and it is as much to do with this specific example as my seeming misunderstanding of how javascript (I'm new to it) variables work. When the user pans and zooms, but the search result is the same, I would prefer NOT to call the "resetMarkers" function, but would rather just leave the map alone. The map will currently always resetMarkers/sidebar/etc and this causes a little bit of a flicker of markers on the screen.
I've tried several different versions of this, but doesn't work. In map.js:
var __markers;
var __oldmarkers;
function resetMarkers(handler, json_array) {
if(!(_.isEqual(__oldmarkers, __markers))) {
handler.removeMarkers(__markers);
clearSidebar();
clearZipcode();
if (json_array.length > 0) {
__markers = handler.addMarkers(json_array);
_.each(json_array, function(json, index){
json.marker = __markers[index];
});
createSidebar(json_array);
}
__oldmarkers = __markers.slice(0);
}
};
Since __markers seems to hold its value through the life of the page (we use it to remove the old markers before we set new ones), I thought I could simply create another variable to check against it. However, it's always false even when I think it should be true.
Another thing I've tried is to resubmit the old hash as a parameter with every search request and then set a flag, but this seems complicated, and the string/hash/array manipulation got so confusing I gave up. I don't really think this would be the best approach, but perhaps I should do it that way?
Or, is there something I am missing completely and should be doing instead?
Your problem lies in comparing both lists of markers to decide whether you should update or not.
The thing is, although
_.isEqual(__oldmarkers, __markers)
does perform a deep comparison, there might be things in Marker instances within your list that change even for identical points (an id, timestamps, ...).Or perhaps it's simply because at the start, both
__markers
and__oldMarkers
arenull
, thus equal, meaning you never get inside theif
block.Anyway, I think deep comparison here could become too costly. What I'd do instead, is compare things that are comparable easily, like the flat list of coordinates for each set of markers.
Something like this:
Here
__coordinates
andcoordinates
are just flat arrays of String, which should be compared quickly and give you the expected results.In ordered to be compared using
_.isEqual
, both arrays are sorted beforehand.NB: Old code used _.difference but that wasn't correct (see discussion in comments)
(Note that I'm using
_.difference
though, probably more costly than_.isEqual
but with the bonus of being independent of the returned markers order.)edit: Oh and of course you can stop sending that "oldHash" in the search query params now ;)