FULL POST

0

Geosearch with MongoDB and Geocoder

Find the perfect shop near you when you are in a hurry and need something is important, so today we're gonna do a geosearch example to help our users and make their lives easier.

The beginning: the app where your data is

As usual in my related posts about Rails apps, we'll use rails apps composer to create our very simple example. We'll have to follow the same steps as always:

$ mkdir myapp
$ cd myapp
$ rvm use ruby-2.2.2@myapp --ruby-version --create
$ gem install rails
$ gem install rails_apps_composer
$ gem install rvm # (only needed if creating a project-specific rvm gemset)
$ rails_apps_composer new . -r core

After answering the questions that the gem makes us, we'll have our Rails application. We try to specify the most basic options. In my case I chose that I would generate a Rails application example, based on bootstrap framework, with only the home and about views, and due to we're going to use MongoDB, we have to skip ActiveRecord when they ask for.

Then we only need to do a scaffold in order to make the model we need for the example (in my case, Shop). So, with that, and adding some HTML and CSS, we'll finish with something like this:

screenshot List of shops

screenshot About view

To use MongoDB instead of MySQL we're going to use MongoID. As you could see in his documentation, is very simple to integrate in our Rails application. We only need to add the gem in our Gemfile, do the bundle install thing, and execute the mongoid generator in order to get the mongoid.yml file with the configuration of our database:

gem 'mongoid'  
$ bundle install
$ rails g mongoid:config

The final mongoid.yml file will looks something like this:

development:  
  clients:
    default:
      database: geosearch_with_mongodb_dev
      hosts:
        - localhost:27017
...        

The fields that we'll define for our Shop model are (don't worry, as always, at the end of the post I'll put links to the repo with the final code to the example mounted on Heroku):

field :name, type: String  
field :description, type: String  
field :address, type: String  
field :image_url, type: String  
field :coordinates, :type => Array  

I assume that you know how MongoID works (no migrations, fields defined in our models...). Maybe in one future post I'll explain more in detail the particularities of NoSQL databases. Ok, now that we have our simple app with Shop model and some entries, let's do some geosearch!

Geocoder, the gem that enhance the MongoDB geosearch capabilities

MongoID has out of the box the capability of do geosearchs with our models as you could see in MongoDB documentation, but in order to geolocate our shops and be able to search by locations, we could enhance a little more this capability and do it easier with Geocoder.

Is as simple as add the gem in our Gemfile and follow their documentation:

gem 'geocoder'  
$ bundle install

After that, we're ready to change our Shop model to add all his functionality. First, we have to include the Geocoder::Model::Mongoid module and then call geocoded_by:

include Geocoder::Model::Mongoid

geocoded_by :address               # can also be an IP address  
after_validation :geocode          # auto-fetch coordinates  

This allows our app to automatically extract the coordinates of our model with nothing more than complete the address field. We could do too the inverse thing, completing the address field automatically with the coordinates we insert. To do this, we have to add this lines in our model:

reverse_geocoded_by :coordinates  
after_validation :reverse_geocode  # auto-fetch address  

Now we have to execute the following rake to generate the necessary spatial indices in our database:

rake db:mongoid:create_indexes  

To avoid unnecessary executions of the automatically filling of the fields, we could set a validation to check if is necessary to do that or not:

after_validation :reverse_geocode, if: ->(obj){ obj.coordinates.present? }  
after_validation :geocode, if: ->(obj){ obj.address.present? }  

So, at the end, we'll have a Shop model like this:

class Shop  
  include Mongoid::Document
  include Mongoid::Timestamps
  include Geocoder::Model::Mongoid

  field :name, type: String
  field :description, type: String
  field :address, type: String
  field :image_url, type: String
  field :coordinates, :type => Array

  geocoded_by :address
  reverse_geocoded_by :coordinates
  after_validation :reverse_geocode, if: ->(obj){ obj.coordinates.present? }
  after_validation :geocode, if: ->(obj){ obj.address.present? }
end  

Geocoder has lot of more features than this, you could check it in their documentation, but for the purpose of this post with that we have enough. Next step, do some searches and display the results to the user!

Searching and drawing some maps with GMapz

Firstly, let's develop an action in our ApplicationController to send our search and return some results. Nothing very fancy, as simply as you could see in this block of code:

def search_shops  
 address_coordinates = Geocoder.coordinates(params[:query])
 shops = Shop.near(address_coordinates, params[:distance], units: :km)
             .only(:description, :coordinates)
             .limit(params[:limit])

 render json: { response: shops }
end  

With Geocoder.coordinates(params[:query]), Geocoder determines the spatial coordinates of the address that we introduce in our search form. To find shops near that address, we simply use the near method that Geocoder gives to our Shop model, passing as parameters of the method the address coordinates, and optionally, the maximum distance that we let, and the units of that distance (e.g., km).

Once the near method give the proper results, we only have to return this results as json to work with this information.

So, the idea is to send an Ajax request with the corresponding params, and in the success event, draw the shops on a Google Map. For this, we're going to use GMapz, a (work in progress) javascript library made by one of my co-workers, one of the brightest minds I know when it comes to the Frontend world, Carlos Cabo. Is simple, is easy to use, and is powerful enough to cover practically all the needs that you could have if you need to integrate Google Maps on your site.

For that, I'll use the gem that I had made with his library, ported to a Rails gem (you could see how to gemify a javascript library or any asset on this post). You could see the gem here (is also in work in progress). So, let's add the gem to our Gemfile and do the bundle install thing:

gem 'gmapz_rails'  
$ bundle install

Then, simply add this line to your js manifest file:

//= require gmapz_rails

And we're ready to go. All the documentation about GMapz is here, please check it if you want to know all the capabilities of this library, but let's see some simple options to prepare our map. In our javascript code, as first step we're going to put this:

GMapz.debug = true;

GMapz.pins = {  
  'default': {
    pin: {
      img: 'http://maps.google.com/mapfiles/ms/micons/purple.png',
      size: [32.0, 32.0],
      anchor: [16.0, 32.0]
    }
  }
};

We're enabling debug mode for our maps (this lets put console.log messages on the browser console). The second half of the code give us the possibility of customize the image of the pin of the marker (image, size, position...). In order to finish with the configuration, we could specify some aspects such as the type of map, the initializer center point, the zoom level... After complete all the options, we simply need to initialize the GMapz:

var results_map_options = {  
  mapTypeId: 'ROADMAP',           // Map type
  center: [40.337977, -3.709757], // Center of the map when it initializes
  zoom: 5                         // Zoom level at init
};

var results_map = new GMapz.map(  
  $('#results-map'),              // ID of the HTML element where the map will appear
  results_map_options             // Hash of options
);

The last step to have our map ready to display the results of our searches is to determine how we want to behave when all is properly charged and ready to go. With the piece of code we have here we are binding the click event of the search button to execute the ask_for_shops() function:

results_map.onReady = function() {  
  $(document).on('click', '#search', function(event) {
    ask_for_shops();
  });

  markers_array = $.map(this.markers, function(val, idx){
    return [val];
  });
};

The code of this function is:

function ask_for_shops() {  
  $.ajax({
    url: '/search_shops',
    type: 'json',
    method: 'get',
    data: { query: $('#address').val(), distance: $('#distance').val(), limit: $('#limit').val() },
    success: function(data) {
      if(data['response'].length == 0) {
        $('#bs-callout-error').removeClass('hide');
        $('#message-error').html('No shops found with the data of the request. Try again with other address. Clue: try "Calle Uria, Oviedo"');
      } else {
        var results = {};
        var i = 0;
        $('#bs-callout-error').addClass('hide');
        $.each(data['response'], function(index, element) {
          results[i] = { lat: element.coordinates[1],
                         lng: element.coordinates[0],
                         iw: element.description };
          i += 1;
        });
        results_map.addLocations(results).centerToMarker(0, 14);
        $('#distance').val('');
        $('#limit').val('');
        $('#address').val('');
      }
    },
    error: function() {
      $('#bs-callout-error').removeClass('hide');
      $('#message-error').html('There has been some error to process the request. Please complete all data.');
    }
  });
}

As you could see, is simply an ajax request to the action we develop earlier, sending the info we introduce. On success, we check the data returned. If there are no data returned, we display an error message. In other case, We iterate over the hash returned and we made the hash with the info needed by GMapz to draw the shops. For each of the points to show, GMapz need the latitude, the longitude, and a message to display if we click on the marker.

Finally, for GMapz could display the map in some area of our site, we just have to add a div in your HTML with the id that we specified when creating the new map:

<div id="map-wrapper">  
  <div class="map" id="results-map"> </div>
</div>  

And that's all folks! Now you can do some searches and see how all works as a charm. Here you have some screenshots of the final result:

screenshot GMapz and search form

screenshot Doing a search

screenshot GMapz with results

Note: I have not gone into detail specifying the code of the form and some more details to avoid excessively increasing the post, again, don't worry, at the end of the post I'll put links to the repo with the final code and to the example mounted on Heroku).

Any place where I can see the result?

Yeah! Here you have the Github repo for this example, so you can clone it, change it, play with it... whatever you want! :)

If you don't want to play with the example repo, here you have a working example mounted on Heroku.

COMMENTS