Take the 2-minute tour ×
WordPress Development Stack Exchange is a question and answer site for WordPress developers and administrators. It's 100% free, no registration required.

I am working on a local business directory site that will use custom post types for the business entries. One of the fields will be "Zip Code." How can I set up a location based search? I'd like visitors to be able to enter their zip code and choose a category and show all the businesses withing a certain radius, or all the businesses ordered by distance. I saw a couple plugins that claim to do this but they don't support wordpress 3.0. Any suggestions?

share|improve this question
2  
I'm starting a bounty because this is an interesting and challenging question. I have a few ideas of my own ... but I want to see if anyone can come up with something more elegant (and easier to build). –  EAMann Oct 3 '10 at 14:53
    
Thanks EAMann. This may help: briancray.com/2009/04/01/… –  matt Oct 3 '10 at 16:40
    
only suggestion I would currently have here is to utilize a plugin such as pods –  NetConstructor.com Oct 4 '10 at 8:19
    
@NetConstructor.com - I wouldn't suggest Pods for this; there really is not benefit Pods provides this problem vs. Custom Post Types. –  MikeSchinkel Oct 4 '10 at 23:36
    
@matt: I've recently implemented something very similar to this although the site is not finalized nor deployed yet. There is quite a bit too it, actually. I plan to package it up as a store locator plugin at some point but it's not some that I can post as a general solution just yet. Contact me offline and I might be able to help if you don't get the answer you need. –  MikeSchinkel Oct 4 '10 at 23:38

3 Answers 3

I would modify the answer from gabrielk and the linked blog post by using database indexes and minimizing the number of actual distance calculations.

If you know the coordinates of the user and you know the maximum distance (say 10km), you can draw a bounding box that is 20km by 20km with the current location in the middle. Get these bounding coordinates and query only stores between these latitudes and longitudes. Do not yet use trigonometry functions in your database query, as this will prevent indexes from being used. (So you might get a store that is 12km away from you if it is in the north-east corner of the bounding box, but we throw it out in the next step.)

Only calculate the distance (as the bird flies or with actual driving directions, as you prefer) for the few stores that are returned. This will drastically improve processing time if you have a large number of stores.

For the related lookup ("give the ten nearest stores") you can do a similar lookup, but with an initial distance guess (so you start with a 10km by 10km area, and if you don't have enough stores you expand it to 20km by 20km and so on). For this initial distance guess you once calculate the number of stores over the total area and use that. Or log the number of queries needed and adapt over time.

I added a full code example at Mike's related question, and here is an extension that gives you closest X locations (quick and barely tested):

class Monkeyman_Geo_ClosestX extends Monkeyman_Geo
{
    public static $closestXStartDistanceKm = 10;
    public static $closestXMaxDistanceKm = 1000; // Don't search beyond this

    public function addAdminPages()
    {
        parent::addAdminPages();
        add_management_page( 'Location closest test', 'Location closest test', 'edit_posts', __FILE__ . 'closesttest', array(&$this, 'doClosestTestPage'));
    }

    public function doClosestTestPage()
    {
        if (!array_key_exists('search', $_REQUEST)) {
            $default_lat = ini_get('date.default_latitude');
            $default_lon = ini_get('date.default_longitude');

            echo <<<EOF
<form action="" method="post">
    <p>Number of posts: <input size="5" name="post_count" value="10"/></p>
    <p>Center latitude: <input size="10" name="center_lat" value="{$default_lat}"/>
        <br/>Center longitude: <input size="10" name="center_lon" value="{$default_lon}"/></p>
    <p><input type="submit" name="search" value="Search!"/></p>
</form>
EOF;
            return;
        }
        $post_count = intval($_REQUEST['post_count']);
        $center_lon = floatval($_REQUEST['center_lon']);
        $center_lat = floatval($_REQUEST['center_lat']);

        var_dump(self::getClosestXPosts($center_lon, $center_lat, $post_count));
    }

    /**
     * Get the closest X posts to a given location
     *
     * This might return more than X results, and never more than
     * self::$closestXMaxDistanceKm away (to prevent endless searching)
     * The results are sorted by distance
     *
     * The algorithm starts with all locations no further than
     * self::$closestXStartDistanceKm, and then grows this area
     * (by doubling the distance) until enough matches are found.
     *
     * The number of expensive calculations should be minimized.
     */
    public static function getClosestXPosts($center_lon, $center_lat, $post_count)
    {
        $search_distance = self::$closestXStartDistanceKm;
        $close_posts = array();
        while (count($close_posts) < $post_count && $search_distance < self::$closestXMaxDistanceKm) {
            list($north_lat, $east_lon, $south_lat, $west_lon) = self::getBoundingBox($center_lat, $center_lon, $search_distance);

            $geo_posts = self::getPostsInBoundingBox($north_lat, $east_lon, $south_lat, $west_lon);


            foreach ($geo_posts as $geo_post) {
                if (array_key_exists($geo_post->post_id, $close_posts)) {
                    continue;
                }
                $post_lat = floatval($geo_post->lat);
                $post_lon = floatval($geo_post->lon);
                $post_distance = self::calculateDistanceKm($center_lat, $center_lon, $post_lat, $post_lon);
                if ($post_distance < $search_distance) {
                    // Only include those that are in the the circle radius, not bounding box, otherwise we might miss some closer in the next step
                    $close_posts[$geo_post->post_id] = $post_distance;
                }
            }

            $search_distance *= 2;
        }

        asort($close_posts);

        return $close_posts;
    }

}

$monkeyman_Geo_ClosestX_instace = new Monkeyman_Geo_ClosestX();
share|improve this answer

First you need a table that looks something like this:

zip_code    lat     lon
10001       40.77    73.98

...populated for each zip code. You can expand on this by adding city and state fields if you want to look up that way.

Then each store can be given a zip code, and when you need to calculate distance you can join the lat/long table to the store data.

Then you'll query that table to get the latitude and longitude for the store and user's zip codes. Once you get it you can populate your array and pass it to a "get distance" function:

$user_location = array(
    'latitude' => 42.75,
    'longitude' => 73.80,
);

$output = array();
$results = $wpdb->get_results("SELECT id, zip_code, lat, lon FROM store_table");
foreach ( $results as $store ) {
    $store_location = array(
        'zip_code' => $store->zip_code, // 10001
        'latitude' => $store->lat, // 40.77
        'longitude' => $store->lon, // 73.98
    );

    $distance = get_distance($store_location, $user_location, 'miles');

    $output[$distance][$store->id] = $store_location;
}

ksort($output);

foreach ($output as $distance => $store) {
    foreach ( $store as $id => $location ) {
        echo 'Store ' . $id . ' is ' . $distance . ' away';
    }
}

function get_distance($store_location, $user_location, $units = 'miles') {
    if ( $store_location['longitude'] == $user_location['longitude'] &&
    $store_location['latitude'] == $user_location['latitude']){
        return 0;

    $theta = ($store_location['longitude'] - $user_location['longitude']);
    $distance = sin(deg2rad($store_location['latitude'])) * sin(deg2rad($user_location['latitude'])) + cos(deg2rad($store_location['latitude'])) * cos(deg2rad($user_location['latitude'])) * cos(deg2rad($theta));
    $distance = acos($distance);
    $distance = rad2deg($distance);
    $distance = $distance * 60 * 1.1515;

    if ( 'kilometers' == $units ) {
        $distance = $distance * 1.609344;
    }

    return round($distance);
}

This is meant as a proof-of-concept, not code that I would actually recommend implementing. If you have 10,000 stores, for example, it would be a pretty expensive operation to query them all and loop through and sort them on every request.

share|improve this answer
    
Could the results be cached? Also, would it be easier to query one of the commerically available (or free if there is one) zip code databases? –  matt Oct 5 '10 at 17:03
1  
@matt - What do you mean by query one of the commercially or free available? Caching should always be possible, see codex.wordpress.org/Transients_API –  hakre Oct 6 '10 at 10:08
    
@hakre: Nevermind, I think I'm talking over my head now. I'm talking about using a zip code databases (USPS, Google Maps...) to get the distances but I didn't realize that they probably don't store the distances, they just store the zip code and coordinates and it will be up to me to calculate it. –  matt Oct 7 '10 at 20:32

The MySQL documentation also includes information on spatial extensions. Oddly, the standard distance() function isn't available, but check this page: http://dev.mysql.com/tech-resources/articles/4.1/gis-with-mysql.html for details on how to "convert the two POINT values to a LINESTRING and then compute the length of that."

Note that every vendor is likely to offer different latitudes and longitudes representing the "centroid" of a zip code. It's also worth knowing that there are no real defined zip code "boundary" files. Each vendor will have its own set of boundaries that roughly match the specific lists of addresses that make up a USPS zip code. (For instance, in some "boundaries" you would need to include both sides of the street, in others, just one.) ZIP Code Tabulation Areas (ZCTAs), widely used by vendors, "do not precisely depict ZIP Code delivery areas, and do not include all ZIP Codes used for mail delivery" http://www.census.gov/geo/www/cob/zt_metadata.html

Many downtown businesses will have their own zip code. You'll want as complete a dataset as possible, so be sure that you find a list of zipcodes that includes both "point" zip codes (usually businesses), and "boundary" zip codes.

I have experience working with the zipcode data from http://www.semaphorecorp.com/. Even that wasn't 100% accurate. For instance, when my campus took on a new mailing address and new zip code, the zip was misplaced. That said, it was the only data source I found that even HAD the new zip code at all, so soon after it was created.

I had a recipe in my book for exactly how to meet your request... in Drupal. It relied on the Google Maps Tools module ( http://drupal.org/project/gmaps, not to be confused with http://drupal.org/project/gmap, also a worthy module.) You might find some helpful sample code in those modules, though of course, they won't work out of the box in WordPress.

share|improve this answer

Your Answer

 
discard

By posting your answer, you agree to the privacy policy and terms of service.

Not the answer you're looking for? Browse other questions tagged or ask your own question.