How to implement spatial search with Sunspot and Solr.
Have you wondered what set of technologies and tools will dominate in 2011?. Well… I have no doubt 2011 will be all about mobile and geo-based applications.
In one of the projects we are currently working on, we are building an iPhone application backed by a web application with built-in spatial (aka geo-location based) search engine.
Project is still in a stealth mode and will be released and available for a public use at the beginning of April. Nevertheless I would like to share with you tutorial how to implement spatial search with Rails, Sunspot, Solr and some code based on Google Maps API.
Application we are working on benefits from the latest version of Google API and is built on the top of Rails 2.3.8. Unfortunately some of the gems we depend on are not compatible with the Rails 3 yet …so we cannot live on the edge ;)
For the purpose of this post I will use Rails 2.3.8 and YM4R/GM plugin to make things quick and easy. Please be aware YM4R/GM works against Google Maps API v2.
The goal of this tutorial is to build application that will allow to find shops in a given location scope.
Get the ball rolling
First of all, let's create a brand new rails project and add controller with a three actions.
rails shops_finder --database=mysql script/generate controller -s shops index new create
In the routes.rb file we will need to define routing to our controller.
map.resources :shops map.root :controller => 'shops'
We have defined our root controller. Please remember to delete public/index.html.
For the purpose of this tutorial and in order to make it super easy to use Google Maps from our Ruby code I will add YM4R/GM plugin to our project.
script/plugin install svn://rubyforge.org/var/svn/ym4r/Plugins/GM/trunk/ym4r_gm
Next step will be to generate Google Maps API Key and paste it to config/gmaps_api_key.yml
Let's dive into code
We are now ready to dive into code and implement the first action in our controller…
class ShopsController < ApplicationController def index @map = GMap.new("map_div") # creates Google Map object in 'map_div' division element @map.control_init(:large_map => true, :map_type => true, :scale => true, :overview_map => true) # enables some Google Maps features @map.interface_init({:scroll_wheel_zoom => true, :continuous_zoom => true}) # enables scrolling with mouse wheel and smooth zoom animation @map.center_zoom_init([52.129918, 21.073086], 8) # centers map on given location and sets zoom to 8 end end
…and add presentation
# layouts/application.html.erb <!DOCTYPE html><html> <head> <title>Shops Finder</title> <meta http-equiv="content-type" content="text/html; charset=utf-8"/> <%= stylesheet_link_tag :all %> <%= GMap.header %> <%= @map.to_html if @map %> </head> <body> <%= yield :content %> </body> </html>
Please pay attention to GMap.header and @map.to_html It will include the Google Maps API v2 code and add JavaScript helper functions from YM4R/GM.
# app/views/shops/index.html.erb <% content_for :content do %> <h1><%= link_to 'Shops', shops_path %></h1> <div id="main"> <%= @map.div %> </div> <% end %>
Please update application.css with some CSS code to make things a little more friendly for our eyes, create database rake db:create and start server script/server.
Let's move forward. We will add functionality to store a new shop in our database.
In order to store geographical locations in mysql database we have to install and add to our environment.rb two gems:
$ sudo gem install GeoRuby $ sudo gem install spatial_adapter
config.gem 'GeoRuby', :lib => "geo_ruby" config.gem 'spatial_adapter'
Required dependencies are in place so we can create Shop model class.
script/generate model -s Shop name:string description:text link:string geom:point
class Shop < ActiveRecord::Base before_validation :fix_url validates_presence_of :name private def fix_url self.link = 'http://' + self.link if self.link && !self.link.empty? && !self.link.downcase.start_with?('http') end end
Let's run migration rake db:migrate and add double click event to our map container in the body of index action of shops controller.
@map.event_init(@map, :dblclick, "function(marker,point) { var url = 'http://localhost:3000/shops/new?lat=' + point.lat() + '&lng=' + point.lng(); window.location.href = url } ")
I know it is not the best code ever - action url is hard coded in the controller, there is inline JavaScript code. Please forgive me it and treat my article as quick and dirty introduction to spatial search with solr and sunspot rather than a reference of a clean code.
In the next step let's update our controller with the code to store geographical location that we double clicked on the map.
def new @shop = Shop.new end def create @shop = Shop.new(params[:shop]) @shop.geom = Point.from_x_y(params[:lng], params[:lat]) # storing geographical location picked from map if @shop.save redirect_to shops_path else render :action => 'new' end end
# app/views/shops/new.html.erb <% content_for :content do %> <div id="main"> <h1>Add shop</h1> <% form_for @shop do |f| %> <fieldset> <dl> <dt><%= f.label :name %></dt> <dd><%= f.text_field :name %></dd> </dl> <dl> <dt><%= f.label :description %></dt> <dd><%= f.text_area :description %></dd> </dl> <dl> <dt><%= f.label :link %></dt> <dd><%= f.text_field :link %></dd> </dl> <%= hidden_field_tag :lat, params['lat'] %> <%= hidden_field_tag :lng, params['lng'] %> <%= f.submit 'Add' %> </fieldset> <% end %> </div> <% end %>
Let's restart our application and have some fun! Double click on the map and add a few shops. You won't it displayed it yet on the map but we may query database and notice that shops are saved and stored.
Let's update index action of shops controller to display our shops on the map.
def index @map = GMap.new("map_div") @map.control_init(:large_map => true, :map_type => true, :scale => true, :overview_map => true) @map.interface_init({:scroll_wheel_zoom => true, :continuous_zoom => true}) shops = Shop.all markers = [] # adding markers shops.each_with_index do |shop, idx| marker = GMarker.from_georuby(shop.geom, :title => shop.name, :info_window => "<p><a href='#{shop.link}'>#{shop.name}</a><br/>#{shop.description}</p>") # marker with tooltip and info window with shop description @map.declare_init(marker, "shop_marker_#{idx}") @map.overlay_init(marker) markers << marker end if markers.empty? @map.center_zoom_init([52.129918, 21.073086], 8) else if markers.size > 1 @map.center_zoom_on_points_init(* (markers.collect { |x| x.point })) else @map.center_zoom_init(markers[0].point, 8) end end @map.event_init(@map, :dblclick, "function(marker,point) { var url = 'http://localhost:3000/shops/new?lat=' + point.lat() + '&lng=' + point.lng(); window.location.href = url } ") end
Play with the application once again. All added shops will be displayed on the map… and the map itself will be automatically centered and zoomed.
Sunspot to rule them all!
Now it is time for a real fun. We are ready to add sunspot to our stack and build a geo-spatial search engine.
config.gem 'sunspot' config.gem 'sunspot_rails'
Please don't hesitate to check out sunspot page where you can find links to useful resources and tutorial how to add Sunspot search to Rails in 5 minutes or less.
Let's move forward. Now we need to create sunspot.yml file in the config directory.
development: solr: hostname: localhost port: 8982 log_level: INFO
… add require 'sunspot/rails/tasks' to your app’s Rakefile and then start solr instance distributed together with sunspot gem.
$ rake sunspot:solr:start
Solr instance is configured to support spatial searches. Now we need to update our Shop model, make it searchable. It should look like the code snippet below.
class Shop < ActiveRecord::Base before_validation :fix_url validates_presence_of :name searchable do text :name, :description # fulltext search location :coordinates # geographic position indexing time :created_at end private def coordinates Sunspot::Util::Coordinates.new(self.geom.y, self.geom.x) end def fix_url self.link = 'http://' + self.link if self.link && !self.link.empty? && !self.link.downcase.start_with?('http') end end
If we would like to index shops we created earlier we will need to reindex model. Let's open rails console…
$ script/console
…and reindex our shops.
>> Shop.reindex
We are getting very close to our final version of application. The next step is to update our application to make sure that after double click on map we will be able to choose if we want to create new shop or search for shops using keywords and geographical location of last double click.
def index @map = GMap.new("map_div") @map.control_init(:large_map => true, :map_type => true, :scale => true, :overview_map => true) @map.interface_init({:scroll_wheel_zoom => true, :continuous_zoom => true}) if params[:q].present? || (params[:lat].present? && params[:lng].present? && params[:precision].present?) search = Sunspot.search(Shop) do keywords params[:q] unless params[:q].empty? with(:coordinates).near(params[:lat], params[:lng], :precision => params[:precision].to_i) if (params[:lat].present? && params[:lng].present? && params[:precision].present?) end shops = search.results if search else shops = Shop.all end markers = [] shops.each_with_index do |shop, idx| marker = GMarker.from_georuby(shop.geom, :title => shop.name, :info_window => "<p><a href='#{shop.link}'>#{shop.name}</a><br/>#{shop.description}</p>") @map.declare_init(marker, "shop_marker_#{idx}") @map.overlay_init(marker) markers << marker end if markers.empty? @map.center_zoom_init([52.129918, 21.073086], 15) else if markers.size > 1 @map.center_zoom_on_points_init(* (markers.collect { |x| x.point })) else @map.center_zoom_init(markers[0].point, 15) end end @map.event_init(@map, :dblclick, "function(marker,point) { var url = 'http://localhost:3000/shops/new?lat=' + point.lat() + '&lng=' + point.lng(); window.location.href = url } ") end
Updated version of views/new.html.erb template should look like snippet below.
# views/new.html.erb <% content_for :content do %> <div id="main"> <h1>Search for shops</h1> <% form_tag shops_path, :method => :get do %> <fieldset> <dl> <dt><%= label_tag :q, 'Search term' %></dt> <dd><%= text_field_tag :q %></dd> </dl> <dl> <dt><%= label_tag :precision, 'Precision' %></dt> <dd><%= select_tag :precision, options_for_select(3..12, 5) %></dd> </dl> <%= hidden_field_tag :lat, params['lat'] %> <%= hidden_field_tag :lng, params['lng'] %> <%= submit_tag 'Search' %> </fieldset> <% end %> <h1>Add shop</h1> <% form_for @shop do |f| %> <fieldset> <dl> <dt><%= f.label :name %></dt> <dd><%= f.text_field :name %></dd> </dl> <dl> <dt><%= f.label :description %></dt> <dd><%= f.text_area :description %></dd> </dl> <dl> <dt><%= f.label :link %></dt> <dd><%= f.text_field :link %></dd> </dl> <%= hidden_field_tag :lat, params['lat'] %> <%= hidden_field_tag :lng, params['lng'] %> <%= f.submit 'Add' %> </fieldset> <% end %> </div> <% end %>
You are ready to test shops finder! You should be able to search for shops within scope of a given location.
Article turned into quite long tutorial. In one of the coming articles I would like to elaborate a little about precision option. I will share with you why it is called precision, what are the differences in the geo-search between different versions of solr and based on that I will explain difference between miles radius and precision.
I hope you find this tutorial useful. If you have any questions, ideas or you noticed things that I missed please don't hesitate to leave a comments.
Happy coding!



Comments