College Park Crime Reports

Enhancing the Report Data Model and Edit View

At this point I make some enhancements to my data model and add a really great new mapping feature newly available through Google's v2 API: drag-and-drop geocoding.

Better date information and more details

I'd like to change my representation of the crime reports' date and time information from a string to a datetime type. That way I can take advantage of Rails' built-in form widget for setting date and time information. Back when I was getting started, I set up the table with a ruby migration file. Can I update the table the same way?

$ ./script/generate migration datetime                              
      exists  db/migrate
      create  db/migrate/002_datetime.rb
$ ./script/generate migration details 
      exists  db/migrate
      create  db/migrate/003_details.rb

$ vi db/migrate/002_datetime.rb
class Datetime < ActiveRecord::Migration
  def self.up
    change_column(:crime_reports, :date, :datetime)                         
  end

  def self.down
    change_column(:crime_reports, :date, :string)                           
  end
end
$ rake migrate                                                      
(in /blah/blah/blah/blah/projects/cpcr)
== Datetime: migrating ========================================================
-- change_column(:crime_reports, :date, :datetime)
   -> 11.0412s
== Datetime: migrated (11.0413s) ==============================================

== Details: migrating =========================================================
== Details: migrated (0.0000s) ================================================

Well, that's encouraging. What has happened, though, to the string values I had for the date column before, like "October 9, 2006 10:00p.m."? They aren't compatible with the datetime format, so they've been discarded. The value of date for those records is now "0000-00-00 00:00:00". So what I should have done is go back and convert the date values to strings of the date-time format. Then the data would have been preserved. Oh well.

A less destructive migration awaits: adding a text column named details to the table.

$ db/migrate/003_details.rb
class Details < ActiveRecord::Migration
  def self.up
    add_column(:crime_reports, :details, :text)                             
  end

  def self.down
    remove_column(:crime_reports, :details)                                 
  end
end
$ rake migrate
== Details: migrating =========================================================
-- add_column(:crime_reports, :details, :text)
   -> 9.2815s
== Details: migrated (9.2816s) ================================================

By the way, I like migrations, but it's a little annoying that the only way to discover the "version" of your DB is to look inside the database:

mysql> select * from schema_info;

+---------+
| version |
+---------+
|       3 |
+---------+

There must be a better way, but a lot of googling failed to turn it up. You need to know the current version of your database, because to revert to an earlier version you need to supply a target version number:

$ rake migrate VERSION=2

Now I just need to change the form partial view and I'll be able to edit the information the way I like.

$ vi app/views/crime_reports/_form.rhtml
<p><label for="crime_report_address">Address</label><br/>
<%= text_field 'crime_report', 'address', "size" => 40 %></p>

<p><label for="crime_report_date">Date</label><br/>
<%= datetime_select 'crime_report', 'date' %></p>

<p><label for="crime_report_description">Description</label><br/>
<%= text_field 'crime_report', 'description'  %></p>

<p><label for="crime_report_case_number">Case number</label><br/>
<%= text_field 'crime_report', 'case_number'  %></p>

<p><label for="crime_report_lat">Latitude</label>
<%= text_field 'crime_report', 'lat', "size" => 10 %>
by
<label for="crime_report_long">Longitude</label>
<%= text_field 'crime_report', 'long', "size" => 10 %></p>

<p><label for="crime_report_details">Details</label></p>                    
<p><%= text_area 'crime_report', 'details', "rows" => 10 %></p>

There we go. Now I'll take a look.

screenshot of Edit View with improved date controls and details field

You know, having the pulldowns is an improvement, but it would be nice to change the order in which the pulldowns appear: I'd kind of like Month - Day - Year instead of Year - Month - Day. I'm not great at military 24-hour time, either, so I'd prefer AM and PM hours. I'll have to add tinkering with this date control to my to-do list.

'Plus' marks the spot: geocoding on the map

Now, something else occurs to me--two things do actually. First, since Google released its 2.0 version of their maps API, the GMarker class has featured this method, which returns a GLatLng object:

point_marker.getPoint()

Second, it is possible to drag and drop markers on the map. It only requires a bit of code like so:

var marker_options = new Object();
marker_options.icon = marker_icon;
marker_options.draggable = true;
marker_options.bouncy = false;
marker_options.title = "drag me to a location to display its coordinates";
var point_marker = new GMarker(map.getCenter(), marker_options);

These two facts suggest that I ought to be able to move a marker around on the map until I know it's on the right spot, and then I could have some JS get the coordinates of the marker and pass that information to my Edit form. Finding the right coordinates this way would solve a problem I often have with Google Maps, precisely placing an address.

As far as my layman's expertise has it, the geocoding data used by Google (and Mapquest and Microsoft) doesn't map every address out there to a set of coordinates. Instead, it maps certain critical points, like the addresses at the ends of blocks. For instance, if your block runs from 101 Cherry St. to 149 Cherry St., and you live at 125 Cherry, someone looking for your house will be directed to the dead center of the block. Close enough in most cases, but sometimes an address gets interpreted, for one reason or another, as quite some distance from where it should be marked. Also, police reports often include addresses like "2500 block of Main St." But there may be no 2500 Main Street, or 2501, and so you need to do other things to find out that the block of Main Street in question is located between Cherry and Domino Street.

With draggable markers and a way to transfer the marker's info to my form, I can set the marker precisely where I want it to be. Here's how I'll do it.

$ vi public/javascripts/google_maps.js

Near the top of the file, before I define any functions, I add a global declaration for a variable called global_map_ref

DEFAULT_LAT = 38.988369;
DEFAULT_LNG = -76.93511;
// will hold a reference to map for later manipulation
var global_map_ref;
...

This is how I'll keep a reference to the map for use after I'm done defining the map. Why do I need that? Well, I'm thinking ahead to the next thing I want to add. As an agile practitioner, I should hold off, but it's only two lines, and it won't get in the way. Promise. Now I add a function.

function load_one_to_edit() {
  var marker_icon = new GIcon();
  marker_icon.image = "http://www.michaelharrison.ws/images/cpcr/crosshairs.png";
  marker_icon.iconSize = new GSize(17, 17);
  marker_icon.iconAnchor = new GPoint(9, 9);
  marker_icon.infoWindowAnchor = new GPoint(9, 1);
  
  var lat = document.getElementById("crime_report_lat").firstChild.nodeValue;
  var lng = document.getElementById("crime_report_lat").firstChild.nodeValue;
  
  if (lat == '') { lat = DEFAULT_LAT; }
  if (lng == '') { lng = DEFAULT_LNG; }
  var map = generateMap("map");
  map.setCenter(new GLatLng(lat, lng), 13);
  
  // Keep a reference to the map
  global_map_ref = map;
     
  // Add hairline on the center
  var marker_options = new Object();
  marker_options.icon = marker_icon;
  marker_options.draggable = true;
  marker_options.bouncy = false;
  marker_options.title = "drag me to a location to display its coordinates";
  
  var point_marker = new GMarker(map.getCenter(), marker_options);
  
  GEvent.addListener(point_marker, "click", function() {
    var lat = point_marker.getPoint().lat();
    var lng = point_marker.getPoint().lng();
    var innerHTML = "Lat: " + lat + "<br />" +
    "Long: " + lng + "<br />" +
    "<a href='javascript:adjust(" + lat + "," + lng + ")'>Use these coordinates</a>.";
    point_marker.openInfoWindowHtml(innerHTML);
  });
  map.addOverlay(point_marker);
  // map is a JS object: we can give it a property on the fly. 
  // Let's stick a ref to point_marker in it.
  map.point_marker = point_marker;
}

First, I create a unique icon, one that uses a crosshairs graphic I whipped up with my pathetic Photoshop skills. Next, I set the map to center on the location of the report being edited, or at the default coordinates if there are none associated with the report yet (i.e. if it's a new report). Then I copy the reference to the map to my global_map_ref variable.

Now, I create an object called marker_options. This is a convention: the GMarker constructor can take an object as a second argument. The object will then be examined to determine whether it has certain properties and, if so, the values will be applied to properties of the marker. These properties include icon, draggable, bouncy, and title. Setting draggable to true is the important thing here. I use the object as an argument to the GMarker constructor, along with the center coordinates of the map: the marker and center of the map will be at the same point.

The code setting up the event listener is familiar, but there's an addition to the info window's HTML: "<a href='javascript:adjust(" + lat + "," + lng + ")'>Use these coordinates</a>.";. This inserts in the window a link to a function that changes the lat and lng values in the form. Finally, after adding my marker in an overlay, I add an arbitrary property to map that points to my new marker. Thanks to the magic of object-orientation, this property is also accessible through the global_map_ref variable.

Here's the adjust function:

function adjust(lat, lng) {
  var form_element = document.forms[0];
  form_element.crime_report_lat.value = lat;
  form_element.crime_report_long.value = lng;
}

Here's how it works. Say you're creating a new report, and you only have a rough description of an area. The Edit view comes up with the marker in some arbitrary location -- not where you want it, as shown in this screenshot below:

Edit View in which marker starts out at incorrect location

The target marker can be picked up and dragged to the actual area you want to mark. Then, a single click opens the window, with the link "Use these coordinates." Clicking that link updates the latitude and longitude values, as below:

Edit View in which marker position is used to update form values

Pretty straightforward. But how about moving the marker when I change a value in the latitude or longitude fields? That's not so hard either, especially since I've added that global variable global_map_ref to my javascript. I'll add a function to the script file:

$ vi public/javascripts/google_maps.js
function changeLatLng() {
  // recover reference to pointer_marker
  var point_marker = global_map_ref.point_marker;
  var form_element = document.forms[0];
  var new_lat = form_element.crime_report_lat.value;
  var new_lng = form_element.crime_report_long.value;
  var new_point = new GLatLng(new_lat, new_lng);
  point_marker.setPoint(new_point);
}

Now I edit the _form.rhtml file to add the onchange functions. The helper tags accept properties that they pass through to the generated HTML form elements. I've highlighted these property assignments below:

$ vi app/views/crime_reports/_form.rhtml
...
<p><label for="crime_report_lat">Latitude</label>
<%= text_field 'crime_report', 'lat', "size" => 10, 
  "onchange" => "changeLatLng()" %>
by
<label for="crime_report_long">Longitude</label>
<%= text_field 'crime_report', 'long', "size" => 10, 
  "onchange" => "changeLatLng()" %></p>
...

Finally, I just need to edit the layout file app/views/crime_reports/layouts/crime_report.rhtml to add another gLoad option for 'edit'.

..
load_list(reports);
<% elseif (controller.action_name=='edit') -%>
load_one_to_edit();
<% else -%>
load_one();
...

Cool. Now the map and the form update each other when I change the state of either one.

Another (hopefully useful) discussion, this time about AJAX

Rails has excellent AJAX support, so if I wanted to communicate with the server and get an update for some portion of my page, that would be great. Maybe, to introduce the form-map behavior I wanted, I could insert an observe_field helper method into my RHTML view code to watch the latitude or longitude fields and, upon detecting a change, call the controller with a specific action. However, at this point I think that would be overkill: I would be passing info to the server so that it could then send the info back to the client, and then what? Redraw the entire Google map with the new coordinates?

I notice there is a hook for a callback function in observe_field--I could specify a function for the :loading() callback. This would maybe allow me to call a function to update the point marker upon the beginning of an AJAX call. But it's still a waste to have an AJAX call just to harness a side effect.

There's another common use of observe_field, though, and that's to update the data store whenever a key value changes, like the latitude or longitude. But I don't think that makes sense in the case of the crime report. I couldn't do it for a new record--it would try to submit an incomplete report. Also, maybe I don't want the application writing to the database until I'm sure I've got everything right, that is until I hit 'Submit.' What if my computer crashed and incorrect data was on the site for five minutes? That might be a problem. it doesn't seem like the report is ready to submit until all the fields are filled out and the user submits them.

If there were atomic values, things like the ordering of items, or the association of a crime report with a category, that might merit an asynchronous update at the moment it changes. I'd like to use some Rails-enabled AJAX, but this isn't the time. I'll see whether I can identify an opportunity later, as I make further enhancements.