The Wayback Machine - http://web.archive.org/web/20120708071427/http://codequest.eu:80/articles/how-to-implement-spatial-search-with-sunspot-and-solr
17 Feb

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!

Back to top ▲

Have your say!

Comments