Proximity by City or Zip Code in Drupal 6 with Location and Views
The location module for Drupal 6 is a robust module. On most projects, it gives us 100 percent of what we need. Proximity searching based on zip code is built-in and fairly painless to setup. I’ve recently been tasked to also allow proximity searching based on City. As you might imagine, the request is very similar. Both methods require the use of latitude and longitude (decimals). The difference is that instead of querying the database for latitude and longitude based on zip code (it’s usually termed postal code in Views, Location, etc.), we’re asking for the coordinates of a city. You’ll find that many cities have multiple zip codes, each of which is a row in the location module’s zipcodes database table. In this example, I’m not giving a real honest attempt at solving this issue, but rather I just return the first coordinate that matches the city.
I am going to assume that you have a some understanding of Views exposed filters and the Distance/Proximity filter, which comes from the location module. If not, please search the web for a good tutorial about this. We are basically going to use the same type of setup, with some modifications. The modifications we’re making will happen in a custom module. If you’ve never setup a module, please see Creating a Module for a very basic introduction.
In a nutshell, we’re going to add a few exposed filters to the view, and process them manually in order to set a Distance/Proximity filter. Before you get started, make sure you have some nodes in the system for testing. In the examples below, the content type that has associated location data is called a “Walk.” Think of it as a group nature walk. This could be an event, store, or anything else that might have a location. Also, make sure you have imported thezipcodes database table as described in the README for the location module.
To get started, create a view of type location. Next, create a display within this view (I’m using Page for this tutorial). The view in my demo has the following filters. I’m only mentioning the settings that truly matter or are non-default.
- Node: Published = Yes
- Node: Type = Walk
- Location: City
- Exposed
- Filter identifier = city
- Optional
- Location: Postal Code
- Exposed
- Filter identifier = zip
- Optional
Now that you have all of your filters configured, it’d be a good idea to test them. Please test the City and Postal Code filters. They should both be working at this point. More on this in a bit.
NOTE: the order of the filters matters given the way I coded this. If you insert an exposed “Title” filter above the “City” filter, for example, it may break the output if the title filter is left empty. I don’t really have time to provide another example of this, but I did want to mention it. If you put that same exposed “Title” filter BELOW all the fields in this tutorial, it should work fine. If the problem arises in a project, I’ll post the solution I come up with.
Now, for the code. Here’s what it took to get this working in my site. All of this exists within the same module, which I’ve renamed “mymodule” for the purposes of this demo.
First, let’s make sure we validate the exposed filter text inputs. On this site, we’d like to require either a city or a zip code value, but not both. Also, we only want to match zip codes of cities/towns within Connecticut. We’ll use the same zipcodes table for this. This should look familiar to you if you’ve ever used hook_form_alter(). I should note, another requirement on this site was a customized distance field. You’ll see, in the example below, how to make the distance text input a select list, with a set of specific options. We haven’t added this filter yet, but when we do, this will clean it up a bit.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
/** * Implementation of hook_form_alter(). */ function mymodule_form_alter(&$form, &$form_state, $form_id) { switch ($form_id) { case 'views_exposed_form': if ($form['#id'] == 'views-exposed-form-walk-search-page-1') { $form['#validate'][] = 'mymodule_walk_search_validate'; $form['distance']['search_distance'] = array( '#type' => 'select', '#options' => array(3 => '3', 5 => '5', 10 => '10', 100 => '100'), '#title' => t('Distance'), ); } break; } } /** * Validation handler for the Walks exposed filter form */ function mymodule_walk_search_validate($form, $form_state) { // On page load, this validation filter fires, but the user may not have even // had a chance to enter a city or zip. Unfortunately, looking at the $_GET // values seems to be the only way to check if the user submitted the exposed // filters form. $form_state looks identical before and after submission of the form. if (isset($_GET['city']) || isset($_GET['zip'])) { if ($form_state['values']['zip'] != '' && $form_state['values']['city'] != '') { form_set_error('', t('Please enter either city or zip code, not both.')); } if ($form_state['values']['zip'] == '' && $form_state['values']['city'] == '') { form_set_error('', t('Please enter either city or zip code.')); } if ($form_state['values']['zip'] != '') { // Only allow Connecticut zip codes $valid_zips = array(); $query = db_query('SELECT zip FROM {zipcodes} WHERE state = "CT"'); while ($zip = db_fetch_array($query)) { $valid_zips[] = $zip['zip']; } if (!in_array($form_state['values']['zip'], $valid_zips)) { form_set_error('zip', t('Please choose a valid Connecticut city or zip code.')); } } } } |
You should now be able to test the validation. Please test this out a bit to make sure it’s suiting your needs.
So, up to now you should have a working locations view that allows you to get nodes that match a city or zip code. Let’s add the proximity filter now.
Location: Distance / Proximity
- Exposed
- Operator = Proximity (Rectangular)
- Filter identifier = distance
- Optional
- Origin = Use PHP code to determine latitude/longitude
- PHP code for latitude, longitude (make sure you leave the line commented out, exactly as shown)
1 |
//return mymodule_exposed_filter_proximity(views_get_current_view()); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
/** * Return an array of latitude and longitude for a city or zip code * from the values the user entered in an exposed filter * * @param $view * A views view * @return * An array with keys latitude and longitude */ function mymodule_exposed_filter_proximity($view) { // WARNING: City and Zip code validation must be done in a form validation handler! $city = strtoupper($view->exposed_raw_input['city']); $zip = $view->exposed_raw_input['zip']; if ($city != '') { return mymodule_get_latlong('city', $city); } if ($zip != '') { return mymodule_get_latlong('zip', $zip); } } /** * Return the latitude and longitude of a city or zip code * * @param $type * Field to search with; 'city' or 'zip' * @param $search_term * Term to use for the search * @return * An array with keys latitude and longitude */ function mymodule_get_latlong($type, $search_term) { if ($type == 'city') { $query = db_query('SELECT latitude, longitude FROM {zipcodes} WHERE state = "CT" AND city = UPPER("%s") LIMIT 1', $search_term); } if ($type == 'zip') { $query = db_query('SELECT latitude, longitude FROM {zipcodes} WHERE zip = "%s" LIMIT 1', $search_term); } while ($location = db_fetch_object($query)) { return array( 'latitude' => (float) $location->latitude, 'longitude' => (float) $location->longitude, ); } } |
1 2 3 4 5 6 7 8 9 10 |
SELECT location.lid AS lid, node.title AS node_title, node.nid AS node_nid FROM location location LEFT JOIN location_instance location_instance ON location.lid = location_instance.lid LEFT JOIN node node ON location_instance.vid = node.vid WHERE (node.status <> 0) AND (node.type in ('walk')) AND (UPPER(location.city) = UPPER('Hartford')) AND (location.latitude > 41.635283798594 AND location.latitude < 41.924856201406 AND location.longitude > -72.871258593942 AND location.longitude < -72.482939406058) |
So we’re almost there. Notice that we’re now matching, hopefully accurately, nodes within 10 miles of Hartford. Also, note that during this test I had the Case sensitive option disabled for the Location: City input. That is the reason for the UPPER within the query. If you notice in the recent code addition, we implemented the same case-insensitivity. It’ll give us a better shot at an exact match. Lastly, let’s talk about what’s actually going on here: We’re getting
- all nodes where node is published
- all nodes that are of type walk
- all nodes where location.city = Hartford
- and all nodes within 10 miles of Hartford
Can you see the problem here? The trouble is #3. We do NOT want to limit our search to Hartford because maybe there is a city 8 miles outside of Hartford. The user would expect this to show up in the search results, given that we asked for nodes within 10 miles of Hartford. Please understand that the same issue will happen if we try the zip code field instead. We’ll get an exact zip code match. How do we solve this? Views provides a hook that lets a module alter the views query. This hook, named hook_views_query_alter(), is very powerful. It provides you with full access to the view object and lets you modify the query as desired. In the function below, you will see how to alter the query to remove the effect of the city and zip code exposed filters. Note that this does NOT affect our ability to see the user’s input in your functions; it’s simply letting us remove the exacting WHERE clause additions we do not want. Unfortunately, this isn’t as easy as just removing the WHERE clause parts. You must also be sure to clear out the associated args so that the remaining parts of the WHERE clause receive the appropriate values. Read my comments, maybe they’ll clear this up.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/** * Implementation of hook_views_query_alter(). */ function mymodule_views_query_alter(&$view, &$query) { if ($view->name == 'walk_search' && $view->current_display == 'page_1') { // Remove the exposed filter conditions from the WHERE clause (WHERE city = MyCityInput) // and remove the corresponding args that were passed via the exposed filters // This effectively turns the city and zip code exposed filters into just text fields // that get used in mymodule_exposed_filter_proximity(); $city = $view->exposed_raw_input['city']; $zip = $view->exposed_raw_input['zip']; // Remove the city or zip arguments (from the exposed filters) foreach ($query->where[0]['args'] as $key => $arg) { if ($arg == $city || $arg == $zip) { unset($query->where[0]['args'][$key]); } } // Remove the corresponding WHERE clause additions foreach ($query->where[0]['clauses'] as $key => $clause) { if (strpos($clause, 'postal_code') !== FALSE || strpos($clause, 'city') !== FALSE) { unset($query->where[0]['clauses'][$key]); } } } } |
Let’s take one more look at the resulting SQL query, using the same Hartford city search with a 10-mile proximity filter.
1 2 3 4 5 6 7 8 |
SELECT location.lid AS lid, node.title AS node_title, node.nid AS node_nid FROM location location LEFT JOIN location_instance location_instance ON location.lid = location_instance.lid LEFT JOIN node node ON location_instance.vid = node.vid WHERE (node.status <> 0) AND (node.type in ('walk')) AND (location.latitude > 41.635283798594 AND location.latitude < 41.924856201406 AND location.longitude > -72.871258593942 AND location.longitude < -72.482939406058) |
Well, there you have it. This is one method of letting users search for nodes within a certain proximity of a city or zip code. There are a few other approaches I can think of, most of which are similar to this one; this is certainly not the only way to do this. Lastly, I suggest you read through the code to make sure you fully understand the implementation. Happy coding!