Google Maps on Ruby on Rails
Introduction
Goals
- First goal is easy: employ Google Maps in pantherFotos so that my members can easily place Google Map markers on their photos.
- Develop an abstract/interfaced solution that would allow me to present three types of Google Maps interface: an Editor Interface (allows the user to click on a map point and retrieve the X/Y coordinates, a Small Map Interface (small google map window with limited controls) and finally a larger Google Maps interface that utilises all Google Map controls and provides a popup window which displays images.
- Coming from a Delphi background I wanted to develop an Object Oriented solution that would allow me to manage all of the above three presentations.
- Coming from a Client/Server background... I wanted the Google Map instances to act as clients that query my web server for specific data to render. This data had to be delivered efficiently and quickly.
Develop an Abstract and Interfaced Solution
As described in point 2, I required three presentations of Google Maps to my members, with each presentation offering different controls and window sizes. Ideally I wanted to develop a base class that would instantiate the Google Maps object, set default settings and render map points. In Delphi speak, this composite base class would provide virtual and abstract methods that can be overridden in children classes to alter desired behaviour.Sounds good in theory but can JavaScript do this? My limited experience of JS told me that it can do popup messages and windows (don't we all know it!), navigate a page's DOM and so on... but classes and inheritance?
Well... yes. I was quite surprised after some research and finding Prototype how far JS has come along. Prototype.js defines several JS classes and concepts that allow a great degree of class inheritance and most importantly to me, method overriding. A primer on JavaScript inheritance with Prototyping(not to be confused with Prototype.js) can be found here and here.
Prototype.js defines a Class as:
var Class = {
create: function() {
return function() {
this.initialize.apply(this, arguments);}
Which now allows me to define the GoogleBaseMap class as:var GoogleBaseMap = Class.create();The purpose of GoogleBaseMap is:
GoogleBaseMap.prototype = {
initialize: function() {
this.init.call(arguments);
},
init: function(map_div) {
if (map_div != undefined) {
this.centered = false;
this.rescale = true;
//create the google map object
if (GBrowserIsCompatible()) {
this.map = new GMap2($(map_div));
return this;
} else {
alert('Google Maps not supported on this browser.');
return false;
}
} else {
return false;
}
},
//surface Google's map methods
addControl: function(obj) {
this.map.addControl(obj);
},
setCenterCoords: function( lat, lon, level){
this.map.setCenter(GPoint(lat,lon), level);
},
setCenter: function(point, level) {
this.map.centerAndZoom(point,level);
},
//abstract methods...don't call
setupMap: function() {
alert('Called abstract method. Call appropriate class method instead');
},
setup: function() {
this.addScaleControl();
this.addMapTypeControl();
this.map.enableDoubleClickZoom();
this.map.enableContinuousZoom();
},
//controls
addSmallMapControl: function() {
this.addControl(new GSmallMapControl());
},
addLargeMapControl: function() {
this.addControl(new GLargeMapControl());
},
addMapTypeControl: function() {
this.addControl(new GMapTypeControl())
},
addSmallZoomControl: function() {
this.addControl(new GSmallZoomControl());
},
addScaleControl: function() {
this.addControl(new GScaleControl());
},
addOverviewControl: function() {
this.addControl(new GOverviewMapControl());
},
// virtual methods
addMarker: function(row) {
coords = row.split('^');
marker = new GMarker(new GLatLng(coords[0],coords[1]), {title: coords[2] });
return marker;
},
getMapType: function() {
return G_NORMAL_MAP;
},
getZoomLevel: function(bounds) {
return this.map.getBoundsZoomLevel(bounds);
},
//methods
queryMarkerData: function (qryUrl){
this.queryUrl = qryUrl;
GDownloadUrl(this.queryUrl, this.onDataLoad.bind(this) )
},
requeryMarkerData: function() {
this.rescale = false;
if (this.queryUrl) {this.queryMarkerData(this.queryUrl);}
},
//events
onDataLoad: function(data, responseCode) {
if (data != '-1') {
markers = data.split(';');
if (markers.length > 0) {
batch = [];
for (var i = 0; i < markers.length; i++) {
if (marker = this.addMarker(markers[i])) { batch.push(marker); }
}
//center map on first marker
this.map.clearOverlays();
if (batch.length > 0) {
if (this.rescale) { this.map.setCenter(batch[0].getPoint(), 7);}
//
var bounds = new GLatLngBounds();
var t = this;
batch.each( function(value,index){
bounds.extend(value.getPoint());
t.map.addOverlay(value);
}
)
if (this.rescale) {t.map.setCenter(bounds.getCenter(), t.getZoomLevel(bounds), t.getMapType());}
}
}
} else {
this.map.setCenter(new GLatLng(53,5),5);
}
}
};
- Define a base class that instantiates a Google Maps object.
- Define a base class that provides initialisation and data processing routines that can be used by inherited classes.
- Define methods that can be overridden to alter behaviour.
function loadMap() {
if (mapObj = new GoogleMapViewer('google_map')) {
mapObj.setupMap();
mapObj.queryMarkerData('http://pantherfotos.com/get_your_marker_data_here');
}
}
as a JavaScript in a RHTML View. Using the above function loadMap(), the important methods of GoogleBaseMap to note are:- init acts as the class constructor.
- The critical method here is queryMarkerData, which receives a URL which is queried to return Google Map marker data. queryMarkerData call's Google Maps' GDownloadURL, which takes a URL and a callback method as parameters. Once GDownloadURL retrieves this data OnDataLoad is called to render map markers.
- OnDataLoad processes the queried data and itself calls virtual methods that are overridden by children classes.
Inheriting from the Base Class to Define Concrete Classes
My first inherited class will be:var GoogleLargeMap = Class.create();This class defines what a LargeMap is. In this case a LargeMap class (and all subsequent inherited classes) will contain a LargeMap and Overview controls. GoogleLargeMap is in turn is finally used by:
GoogleLargeMap.prototype=Object.extend(
new GoogleBaseMap(),
{
initialize: function(map_div) {
this.init(map_div);
},
setupMap: function() {
this.setup();
this.addLargeMapControl();
this.addOverviewControl();
}
}
);
var GoogleMapViewer = Class.create();Which is then called from an RHTML view as a JavaScript:
GoogleMapViewer.prototype=Object.extend(
new GoogleLargeMap(),
{
initialize: function(map_div) {
this.init(map_div);
},
//override
addMarker: function(row) {
coords = row.split('^');
var marker = new GMarker(new GLatLng(coords[0],coords[1]), {title: coords[2] });
marker.photo_id = coords[3];
//register listener on marker
GEvent.addListener(marker, "click", function() {
marker.openInfoWindowHtml("Loading details...);
//
queryURL = '/photos/get_photo_window_info/'+marker.photo_id;
GDownloadUrl(queryURL, function(data, responseCode) {
marker.openInfoWindowHtml(data);
});
});
return marker;
}
}
);
function loadMap() {
if (mapObj = new GoogleMapViewer('google_map')) {
mapObj.setupMap();
mapObj.queryMarkerData('http://pantherfotos.com/marker_data');
}
}
Note the following:- The setupMap call is routed to GoogleLargeMap.setupMap (which in effect calls the base setup method then overrides this with specific behaviour for this class).
- GoogleBaseMap defines queryMarkerData in such a way that I only need to override the addMarker method if I wish to deviate from the base class's map marker rendering behaviour. This is the case here where the GoogleMapViewer class hooks an Info Window on each rendered map marker. This allows me to show a photo against all markers. This is demonstrated in the FotoMap section of pantherFotos.
Client/Server Google Maps.
When researching on how to use Google Maps and specifically how to tell the Google Maps object what to render I did stumble onto some questionable code. These examples (mostly PHP) would employ PHP to build an HTML page and JavaScript sections which would call mapObject.AddOverlay(point) in a loop.Having used Ruby on Rails to contruct my maps page, I did not want to use this same method (for obvious sanity reasons). Ideally I wanted to abstract the marker data from the Google Map object. This solution would allow me to define one Google Map object that I can then re-use in rendering various data (i.e. a photographer's markers, a club's markers, all markers and so on). The Google Map object shouldn't care what the data was... it should just render it.
This is the purpose of the queryMarkerData in the GoogleBaseMap class. It receives a URL from which is fetches the data. The next issue was how to transfer the data. I had two options: use XML or make up my own format. I chose the later as I wanted compact and efficient method to pass hundreds if not thousands of marker coordinates to the Google Map object.
The following Ruby module was defined to construct the queries data:
module MapsHelperThis module is included in any Rails controller that is required to provide marker data. This generates the following output which is then parsed and rendered by GoogleBaseMap.OnDataLoad:
def markers_to_markup(markers)
if markers.length > 0
out = []
markers.each{ |m|
out << "#{m.lat}^#{m.long}^#{m.title.gsub('^',' ')}^#{m.markable_id}"
}
return out.join(';')
else
return '-1'
end
end
end
51.3897332^-2.96580433^tree^457;51.35791418^-2.99922466^Birnbeck Pier^455;
In Conclusion...
My final solution includes three Rails partials (one for each map type) that can be called by passing the URL as a local. These partials then construct the Google Maps object, pass it the URL which then renders my map markers. These partials can render any data source as can be seen here, here, here, here and here.
The Rails magic kicked in when I created a new plugin, acts_as_markable which with a single line of code will enable any Rails Model to support Google Map markers.
There is a drawback that has to be mentioned. Although the data sent back during OnDataLoad is compact and efficient, it isn't very extensible as the data format is proprietary to the Google Map object on pantherFotos. That data, cannot for example, be used by other sites. A more extensible solution could be achieved using GeoRSS, which will be the focus of my next article...










