Maps, Geolocalization and Optimization with Maptimize

written by seb on May 1st, 2009 @ 09:30 PM

Playing with maps is great fun and here is walk through from basics to advanced use of geolocalization and how to optimize your maps with Maptimize

During this tutorial you will :

  1. start geo localizing your addresses with server side technologies and caching system
  2. add front side tools (in Javascript) to keep things lighter and nicer for the end user
  3. keep both side geo localization working nicely together and in an unobtrusive way
  4. map your localized object while keeping optimized, using Maptimize
  5. show details when clicking on markers and clusters
  6. bonus, use Maptimize makers when creating / editing entries

UPDATE: Thanks to Sébastien Gruhier from Xilinus I’ve made couple of change to improve various codes and this article in general.

UPDATE 2 – 2009-05-04: Sébastien Gruhier from Xilinus has been rocking again and helped me out improving few aspects.

UPDATE 3 – 2009-05-04: Improve maptimize configuration and task to be more flexible and portable

Feeling in hurry ?

I’ve made a basic demo running on Rails 2.3.2 to cover this tutorial : Maptimize – Demo 1 so feel free to check it out directly. Make sure to check the README.textile to get it working.

With more time, let’s get a step by step walk through

For this integration I’ll assume that you have a Rails application up-and-running (for me using Rails 2.3.2) with a model collecting address details, in my case, “business”

Geolocalize your addresses

You first gonna need to add the lat and lng float attributes in your database but also a geolocalized_address string attribute. This attribute will be used to cache the address and prevent unnecessary updates.

From the demo :

class AddGeolocalizedSupportToBusinesses < ActiveRecord::Migration
  def self.up
    change_table(:businesses) do |t|
       t.float :lat, :lng
       t.string :geolocalized_address
     end
  end

  def self.down
    change_table(:businesses) do |t|
       t.remove :lat, :lng, :geolocalized_address
     end
  end
end

Don’t forget to run the migration rake db:migrate

1. Geolocalize on server side with GeoKit

Make sure to install GeoKit gem and GeoKit rails plugin as well as configuring your KEYS in “config/initializers/geokit_config.rb

GeoKit propose you a really simple way to automatically geolocalize your address :

class Business < ActiveRecord::Base
  acts_as_mappable :auto_geocode=>true
end

In our case, we will do something a bit more advanced by caching the geolocalized address to prevent calling the Geolocalization Services when we don’t need to.

Here is the model that I would like to geolocalize :

class Business < ActiveRecord::Base
  acts_as_mappable
  before_validation :geocode_address

  private
  def geocode_address
    return if self.address.nil? || (self.geolocalized_address == self.address && !self.lat.nil? && !self.lng.nil?)
    geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
    errors.add(:address, "Could not Geocode address") if !geo.success
    self.lat, self.lng, self.geolocalized_address = geo.lat, geo.lng, self.address if geo.success
  end
end

2. Geolocalize on front-end side

As Google will recommend and warn when signing up for the API key their is a geocode request limitation per day based on your ip. Using front side geocoding will prevent you from reaching this limits as the geolocalization request will be done from your customers IP address.

Client side geolocalization will also save server load and allow your customers to adjust the position when needed.

For this purpose, I’m using AddressChooser from Maptimize , and as dealing with a single address field I’m gonna add the prototype / scriptaculous autocomplete example

NOTICE : AddressChooser is Javascript framework-agnostic and Mapping system independent, so if your needs differs from mine go check AddressChooser from Maptimize to get the full details.

Start by including the required Javascripts files :
  1. Prototype JS
  2. Scriptaculous Effects and Controls
  3. AddressChooser
  4. Your own JavaScripts

I actually start to use more and more the Google hosted ones to prevent end users from loading this libraries again and again so here is how I deal with it:

<script type="text/javascript" src="http://www.google.com/jsapi?key=<%= Geokit::Geocoders::google %>"></script>
<%= javascript_include_tag 'gloader', 'effects', 'controls', 'addresschooser/proxy/googlemap', 'addresschooser/addresschooser', 'application' %>
GLoader? What’s that ?

GLoader is a tiny javascript files that take care of loading my required libraries, hosted by Google :

NOTE : ScriptAculoUs is not loaded this way as it can’t be loaded properly right now. To help getting this fixed please review, comments, rate, ... the following issue : google.load / auto-loading and scriptaculous modules .

// Load libraries
google.load("maps", "2");
google.load("prototype", "1.6.0.3");

// on page load complete, initialize the application
google.setOnLoadCallback(function() {
  $(document.body).observe('onunload', GUnload);
  new Application;
});
Add autocomplete CSS

Just add <%= stylesheet_link_tag 'autocomplete' %> in the head of your page

Update your HTML

Now we got our required javascripts files loaded, let’s update our HTML to support Maptimize.AddressChooser

You’ll need to add the following fields and divs to your creation form

  <div id='suggests' class='auto_complete' style='display:none'></div>
  <div id="map" style="position: absolute; top: 10px; left: 400px; height: 350px; width: 350px;"></div>
  <%= f.hidden_field :lat %>
  <%= f.hidden_field :lng %>

While editing, we’ll need some slightly different fields to be able to stay unobtrusive but also take full advantage of the client side geolocalization.

  <div id='suggests' class='auto_complete' style='display:none'></div>
  <div id="map" style="position: absolute; top: 10px; left: 400px; height: 350px; width: 350px;"></div>
  <%= f.hidden_field :current_lat, :value => @business.lat %>
  <%= f.hidden_field :current_lng, :value => @business.lng %>
Initialize Maptimize.AddressChooser

Now we’ve got the HTML ready, our CSS and the required libraries let’s initialize Maptimize.AddressChooser to turn on the magic.

I’ve made couple of changes to work nicely in an unobtrusive way and while creating or editing any entry

From the demo application :

Application = Class.create({
  initialize: function() {
    this.initAddressChooser('business', {street: 'address'});
  },
  initAddressChooser: function(object, options) {
    // Init options
    options = $H({
      street: 'street',
      submit: 'submit',
      lat: 'lat',
      lng: 'lng',
      current_lat: 'current_lat',
      current_lng: 'current_lng',
      suggests: 'suggests'
    }).merge(options);

    // Check if current lat/lng are defined to use them
    var current_lat, current_lng;
    if ((current_lat = $(object+'_'+options.get('current_lat'))) && (current_lng = $(object+'_'+options.get('current_lng'))))
      current_lng.insert({after: '<input type="hidden" id="'+object+'_'+options.get('lat')+'" name="'+object+'['+options.get('lat')+']" value="'+current_lat.value+'" /><input type="hidden" id="'+object+'_'+options.get('lng')+'" name="'+object+'['+options.get('lng')+']" value="'+current_lng.value+'" />'});

    var street, submit;
    if (!(street = $(object+'_'+options.get('street'))) || !(submit = $(object+'_'+options.get('submit')))) return(this);

    // BEGIN AUTOCOMPLETE SETTINGS AND HACKS :)
    // Create a local autocomplete without data. Data will be added dynamically according to map suggestions
    var autocomplete = new Autocompleter.Local(street, options.get('suggests'), [],
      {
        afterUpdateElement: function(element, selectedElement) {
          var index = selectedElement.up().immediateDescendants().indexOf(selectedElement);
          widget.showPlacemark(index);
        },
        selector: function(instance) {
          instance.changed = false;
          return "<ul><li>" + instance.options.array.join('</li><li>') + "</li></ul>";
        }
      }
    );

    // Do not observe keyboard event
    autocomplete.onObserverEvent = function() {}

    // Wrap render to update map with selected placemarks
    autocomplete.render = autocomplete.render.wrap(function(method) {
      method();
      widget.showPlacemark(this.index);
    });
    // END AUTOCOMPLETE SETTINGS AND HACKS :)

    widget = new Maptimize.AddressChooser.Widget(
      { onInitialized: function(widget) {
          // Add default controls
          widget.getMap().setUIToDefault();

          widget.initMap();

          // Observe 'suggests:started' to display spinner and disable submit button
          widget.addEventListener('suggests:started', function() {
            street.addClassName('spinner');
          });

          // Observe 'suggests:found' to hide spinner and enable submit button if a placemark has been found
          widget.addEventListener('suggests:found', function(placemarks) {
            street.removeClassName('spinner');
            street.focus();

            // Reset autocomplete suggestions to new placemarks
            autocomplete.options.array.clear();
            if (placemarks && placemarks.length > 0) {
              for (var i = 0; i < placemarks.length; i++) {
                autocomplete.options.array.push(widget.getAddress(placemarks[i]));
              }
              // For autocomplete update
              autocomplete.getUpdatedChoices();
              autocomplete.show();
            }
            else {
              autocomplete.hide();
            }
          });

          street.focus();
        },
        street: object+'_'+options.get('street'),
        lat: object+'_'+options.get('lat'),
        lng: object+'_'+options.get('lng')
      }
    );

    return(this);
  }
});

3. Take advantage of the front side geolocalization

For this purpose, we have to update our model to update the cached address when needed but also prevent from using the geolocalization on server-side if the client have already done it on front-side.

class Business < ActiveRecord::Base
  attr_accessor :current_lat, :current_lng

  acts_as_mappable
  before_validation :geocode_address

  private
  def geocode_address
    if (self.geolocalized_address != self.address)
      if (self.current_lat == self.lat && self.current_lng == self.lng)
        geo=Geokit::Geocoders::MultiGeocoder.geocode(address)
        errors.add(:address, "Could not Geocode address") if !geo.success
        self.lat, self.lng, self.geolocalized_address = geo.lat, geo.lng, self.address if geo.success
      else
        self.geolocalized_address = self.address
      end
    end
  end
end

You will notice the 2 attributes accessors that will allow to check any client changes on the coordinates.

4. Map you object and keep optimized using Maptimize

When the time comes to work with maps (google maps in my case) and lot of markers, a problem comes up as you grow, TOO MUCH MARKERS!

Too much markers makes you maps loading horribly slow!

For this reasons, the experts at Xilinus came up with a elegant solution to deal with this, Maptimize

Maptimize take care of merging your markers as clusters to reduce your amount of visible items. That keep it much lighter to load but also nicer for the viewer.

I then recommend using Maptimize from the early stage. That keep your application easier to scale but also nicer to the end users.

Signup and config Maptimize

Once signed up, go in the “Security Settings” tab to get your MAP and API KEYS.

From here you could add this keys in your environment’s configuration but I feel that using a dedicated initializer will keep things cleaner and easier to maintain.

In the file config/initializers/maptimize_config.rb with :

# You'll need to add a cronjob to automatically synchronize your data with Maptimize running the following command line
#
# IMPORTANT: This will overwrite any file called *maptimize.csv* located in your app's tmp folder
# If you like to change this behavior update lib/tasks/maptimize.rake
#
# rake maptimize:sync

MAPTIMIZE_CSV_URL = "http://localhost:3000/businesses.csv" 
MAPTIMIZE_CSV_URL.freeze
MAPTIMIZE_AUTHENTICITY_TOKEN = "AUTHENTICITY_TOKEN" 
MAPTIMIZE_AUTHENTICITY_TOKEN.freeze
MAPTIMIZE_MAP_KEY = Rails.env.development? ? "DEVELOPMENT_KEY" : "PRODUCTION_KEY" 
MAPTIMIZE_MAP_KEY.freeze

Send your data to Maptimize

Create a CSV file generator

Thanks to rails, is this trivial.

Update your controller

In your controller update your index action, from the demo “businesses_controller.rb” :

  # GET /businesses
  # GET /businesses.xml
  # GET /businesses.csv
  def index
    @businesses = Business.all

    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @businesses }
      format.csv  # index.csv.erb
    end
  end
Add “app/views/businesses/index.csv.erb
id,lat,lng
<%- @businesses.each do |business| -%>
<%= business.id.to_s + ',' + business.lat.to_s + ',' + business.lng.to_s %>
<%- end -%>

Well, that’s done! If you check http://localhost:3000/businesses.csv you’ll see a simple CSV file containing the minimum data required by Maptimize

Send your CSV file to Maptimize

This is something that you may like to run in a cronjob to keep your data synchronized automatically.

From a bash shell console, just run :

IMPORTANT: This will overwrite any file called “maptimize.csv” located in your app’s tmp folder If you like to change this behavior update lib/tasks/maptimize.rake


rake maptimize:sync

If everything worked as expected, your command should ends with :

<?xml version="1.0" encoding="UTF-8"?>
<import>
  <status>succeeded</status>
</import>

Show your objects on a map

Now that Maptimize have our data, it’s time to use them and get our map

Include the CSS and Javascript files required to integrate Maptimize
In your head section :
<link href="http://www.maptimize.com/stylesheets/cluster.css" rel="stylesheet" type="text/css" />
In the bottom of your page, right after Google jsapi file
<script src="http://www.maptimize.com/api/v1/<%= MAPTIMIZE_MAP_KEY %>/embed.js" type="text/javascript"></script>

Display your map

Add a div that will contain your map

In the demo, I’ve added the div in the index file “app/views/businesses/index.html.erb

<div id="map" style="width: 900px; height: 500px;"></div>
Initialize the map

In my case I’ll update “public/javascripts/application.js” to include :

Application = Class.create({
  initialize: function() {
    this.initAddressChooser('business', {street: 'address'})
      .initMap('map');
  },
  initAddressChooser: function(object, options) {
...
    return(this);
  },
  initMap: function(map) {
    if (!GBrowserIsCompatible() || !(map = $(map)))
      return(this);

    if (typeof Maptimize.Map == "undefined") {
      map.update("Maptimize key is not correctly set, check file config/initializers/maptimize_config.rb or \
                  run 'rake bootstrap' to fill database with some data");
    } else {
      // Create a new google map
      var map = new GMap2(map);

      // Center on the world
      map.setCenter(new GLatLng(47, 1), 2);

      // Add controls
      map.addControl(new GSmallMapControl());

      // Attach maptimize service
      window.maptimizeMap = new Maptimize.Map(map);
    }

    return(this);
  }
});
Show me the real deal!

The see the goodness of Maptimize you need to have a good bunch of entry, for this purpose you can run :

rake bootstrap

It will load 500 entries in your database.

Done?

That’s it! we have a map with all our objects localized, merged when needed, ... it can’t get better!

5. Show details when clicking on markers and clusters

“it can’t get better!” I said? Well it can!

What about showing our object details when we click a marker? Let’s do it!

Update our Maptimize initialization to bind actions on click for clusters and markers

Application = Class.create({
...
  initMap: function(map) {
    if (!GBrowserIsCompatible() || !(map = $(map)))
      return(this);

    if (typeof Maptimize.Map == "undefined") {
      map.update("Maptimize key is not correctly set, check file config/initializers/maptimize_config.rb or \
                  run 'rake bootstrap' to fill database with some data");
    } else {
      // Create a new google map
      var map = new GMap2(map);

      // Center on the world
      map.setCenter(new GLatLng(47, 1), 2);

      // Add controls
      map.addControl(new GSmallMapControl());

      // Attach maptimize service
      var app = this;
      window.maptimizeMap = new Maptimize.Map(map, {
        onMarkerClicked: function(marker) {
          return app.getMarkerDetails(marker, marker.getId());
        },
        onZoomMaxClusterClicked: function(cluster, ids) {
          return app.getMarkerDetails(cluster, ids);
        }
      });
    }

    return(this);
  },
  getMarkerDetails: function(object, ids) {
    return new Ajax.Request("/businesses/"+ids, {
      method: 'GET',
      onComplete: function(response) {
        object.getGMarker().openInfoWindowHtml(response.responseText);
      }
    });
  }
});

As you can see, the id of the marker will be used to call the show action of our object, “business” in this case.

What append when you click a cluster, the id string looks like “1,2,3

For this we will update our action to support multiple ids and show the expected details

Update our controller

Looking at “app/controllers/businesses_controller.rb” we’ll change the show method as follow :

  # GET /businesses/1
  # GET /businesses/1.xml
  # GET /businesses/1,2
  def show
    @businesses = Business.find(params[:id].split(','))
    @business = @businesses.first

    respond_to do |format|
      format.html # show.html.erb
      format.xml  { render :xml => @business }
      format.js   # show.js.erb
    end
  end

The split call will allow us to get more IDS at once, to keep our initial formats working properly I keep the single object as well.

Add the view “app/views/businesses/show.js.erb

<%- @businesses.each do |business| -%>
<p><%= business.name %><br /><small><%= business.address %></small></p>
<%- end -%>

6. Bonus, use Maptimize makers when creating / editing entries

To use the Maptimized map with markers when creating / editing your entries, just do the tiny change in initAddressChooser from “/public/application.js

Include this code just after widget.getMap().setUIToDefault();

// Attach maptimize plugin
var maptimizeMap = new Maptimize.Map(widget.getMap());

Comments

  • Arun Pattnaik on 02 May 19:45

    Great post Seb. But some of the codes are messed up by the green arrow symbol. Please fix them.
  • Sébastien Grosjean - ZenCocoon on 03 May 05:43

    Hi Arun, thanks for your feedback. Actually this green arrows are here to split long lines. If you like to see a nice and clean code, use the "show source" button
  • Mick on 04 Jun 12:39

    Hi Do you have a version online for me to have a look at? Thanks Mick
  • Sebastien Grosjean - ZenCocoon on 19 Jun 19:01

    Hi Mick, There's actually no online demo and that would remove lot of features, specially touching the synchronization. You might need only couple minutes to get the app running and play with all features on your own. If you like to see some live exemple using maptimize, please refer the http://www.maptimize.com . Hope this helps,

Comments are closed