Location: PHPKode > scripts > Zip Codes Range > zip-codes-range/ZipCodesRange.class.php
<?php
/**
 * Class to find zip codes within an approximate
 * distance of another zip code. This can be useful
 * when trying to find retailers within a certain
 * number of miles to a customer.
 *
 * This class makes some assumptions that I consider
 * pretty safe.  First it assumes there is a database
 * that houses all of the zip code information. 
 * Secondly it assumes there is a way to validate a
 * zip code for a given country.  It makes one bad
 * assumption and that is that the world is flat. See
 * comments below for an explanation.
 *
 * @author  Scott Mattocks
 * @created 2004-05-03
 * @updated 2004-05-14
 */
require_once ('DB.php');
define ('DSN', 'pgsql://hide@address.com/database');

class ZipCodesRange {

  /**
   * The conversion factor to go from miles to degrees.
   * @var float
   */
  var $milesToDegrees = .01445;
  /**
   * The zipcode we are starting from.
   * @var string
   */
  var $zipCode;
  /**
   * The maximum distance in miles to return results for.
   * @var float
   */
  var $range;
  /**
   * The country the zip code is in.
   * @var string Two character ISO code.
   */
  var $country;  
  /**
   * The result of our search.
   * array(zip1 => distance, zip2 =>distance,...)
   * @var array
   */
  var $zipCodes = array ();
  /**
   * The database table to look for zipcodes in.
   * @var string
   */
  var $dbTable = 'zips';
  /**
   * The name of the column containing the zip code.
   * @var string
   */
  var $dbZip = 'zip';
  /**
   * The name of the column containing the longitude.
   * @var string
   */
  var $dbLon = 'lon';
  /**
   * The name of the column containing the latitude.
   * @var string
   */
  var $dbLat = 'lat';

  /**
   * Constructor. Calls initialization method.
   *
   * @access private
   * @param  string  $zipCode
   * @param  float   $range
   * @param  string  $country Optional. Defaults to US.
   * @return object
   */
  function ZipCodesRange($zipCode, $range, $country = 'US') {
    
    $this->_initialize($zipCode, $range, $country);
  }

  /**
   * Initialization method. 
   * Checks data and sets member variables.
   *
   * @access private
   * @param  string  $zipCode
   * @param  float   $range
   * @param  string  $country Optional. Defaults to US.
   * @return boolean 
   */   
  function _initialize($zipCode, $range, $country) {
    
    // Check the country.
    if ($this->validateCountry($country)) {
      $this->country = $country;
    } else {
      trigger_error('Invalid country: ' . $country);
      return FALSE;
    }

    // Check the zipcode.
    if ($this->validateZipCode($zipCode, $country)) {
      $this->zipCode = $zipCode;
    } else {
      trigger_error('Invalid zip code: ' . $zipCode);
      return FALSE;
    }

    // We don't need a special method to check the range.
    if (is_numeric($range) && $range >= 0) {
      $this->range = $range;
    } else {
      trigger_error('Invalid range: ' . $range);
      return FALSE;
    }

    // Set up the zip codes.
    return $this->setZipCodesInRange();
  }

  /**
   * Get all of the zip codes from the database.
   * Currently this method is called on construction but
   * it doesn't have to be.
   *
   * @access public
   * @param  none
   * @return boolean
   */
  function setZipCodesInRange() {
    
    // First check that everything is set.
    if (!isset($this->zipCode) || !isset($this->range) || !isset($this->country)) {
      trigger_error('Cannot get zip codes. Class not initialized properly.');
      return FALSE;
    }

    // Get the max longitude and latitude of the starting point.
    $maxCoords = $this->getRangeBox();
    
    // The query.
    $query = 'SELECT ' . $this->dbZip . ', ' . $this->dbLat . ', ';
    $query.=  $this->dbLon . ' ';
    $query.= 'FROM ' . $this->dbTable . ' ';
    $query.= ' WHERE ';
    $query.= '  (' . $this->dbLat . ' <= ' . $maxCoords['max_lat'] . ' ';
    $query.= '    AND ';
    $query.= '   ' . $this->dbLat . ' >= ' . $maxCoords['min_lat'] . ') ';
    $query.= '  AND ';
    $query.= '  (' . $this->dbLon . ' <= ' . $maxCoords['max_lon'] . ' ';
    $query.= '    AND ';
    $query.= '   ' . $this->dbLon . ' >= ' . $maxCoords['min_lon'] . ') ';

    // Query the database.
    $db =& DB::connect(DSN);
    $db->setFetchMode(DB_FETCHMODE_ASSOC);
    $result = $db->query($query);
    
    // Check for errors.
    if (DB::isError($result)) {
      trigger_error('Database error: ' . $result->getMessage . ' ' . $query, E_USER_ERROR);
    }

    // Process each row.
    while ($row = $result->fetchRow()) {

      // Get the distance form the origin (imperfect see below).
      $distance = $this->calculateDistance($row[$this->dbLat], $row[$this->dbLon]);
      // Double check that the distance is within the range.
      if ($distance < $this->range) {
	// Add the zip to the array
	$this->zipCodes[$row[$this->dbZip] = $distance;
      }
    }
    return TRUE;
  }

  /**
   * Return the array of results.
   *
   * @access public
   * @param  none
   * @return &array
   */
  function &getZipCodesInRange() {
    return $this->zipCodes;
  }

  /**
   * Calculate the distance from the coordinates are from the
   * origin zip code.
   *
   * The method is quite imperfect.  It assumes as flat Earth.
   * The values are quite accurate (depending on the conversion
   * factor used) for zip codes close  to the equator. I found
   * some crazy formula for calulating distance on a sphere
   * but I am not good enough at calculus to convert that into
   * working code.
   *
   * @access public
   * @param  float $lat       The latitude you want to know the distance to.
   * @param  float $lon       The longitude you want to know the distance to.
   * @param  float $zip       The zip code you want to know the distance from.
   * @param  int   $percision The number of decimals places in the distance.
   * @return float            The distance from the zip code to the coordinates.
   */
  function calculateDistance($lat, $lon, $zip = NULL, $percision = 2) {
    
    // Check the zip first.
    if (!isset ($zip)) {
      // Make it default to the origin zip code.
      // Could be used to calculate distances from other points.
      $zip = $this->zipCode;
    }
    // Get the coordinates of our starting zip code.
    list ($starting_lon, $starting_lat) = $this->getLonLat($zip);

    // Get the difference in miles for both coordinates.
    $diffLonMiles = ($starting_lon - $lon) / $this->milesToDegrees;
    $diffLatMiles = ($starting_lat - $lat) / $this->milesToDegrees;
    
    // Calculate the distance between two points.
    $distance = sqrt(($diffLonMiles * $diffLonMiles) + ($diffLatMiles * $diffLatMiles));

    // Return the distance rounded to the defined percision.
    return round($distance, $percision);
  }

  /**
   * Get the longitude and latitude for a single zip code.
   *
   * @access public
   * @param  string $zip  The zipcode to get the coordinates for.
   * @return array  Numerically index with longitude first.
   */
  function getLonLat($zip) {
    
    // Get the longitude and latitude for the zip code.
    $query = 'SELECT ' . $this->dbLon . ', ' . $this->dbLat . ' ';
    $query.= 'FROM ' . $this->dbTable . ' ';
    $query.= 'WHERE ' . $this->dbZip . ' = \'' . addslashes($zip) . '\' ';

    $db =& DB::connect(DSN);

    return $db->getRow($query);
  }

  /**
   * Check to see if the country is valid.
   *
   * Not implemented in any useful manner.
   *
   * @access public
   * @param  string  $country The country to check.
   * @return boolean
   */
  function validateCountry($country) {

    return (strlen($country) == 2);
  }

  /**
   * Check to see if a zip code is valid.
   *
   * Not implemented in any useful manner.
   *
   * @access public
   * @param  string $zip     The code to validate.
   * @param  string $country The country the zip code is in.
   * @return boolean
   */
  function validateZipCode($zip, $country = NULL) {
    
    // Set the country if we need to.
    if (!isset($country)) {
      $country = $this->country;
    }
    
    // There should be a way to check the zip code for every
    // acceptabe country.
    return TRUE;
  }

  /**
   * Get the maximum and minimum longitude and latitude values
   * that our zip codes can be in.
   *
   * Not all zipcodes in this box will be with in the range.
   * The closest edge of this box is exactly range miles away
   * from the origin but the corners are sqrt(2(range^2)) miles
   * away. That is why we have to double check the ranges.
   *
   * @access public
   * @param  none
   * @return &array The edges of the box.
   */
  function &getRangeBox() {

    // Calculate the degree range using the mile range
    $degrees = $this->range * $this->milesToDegrees;

    // Get the coords for our starting zip code.
    list($starting_lon, $starting_lat) = $this->getLonLat($this->zipCode);
    
    // Set up an array to return.
    $ret_array = array ();

    // Lat/Lon ranges 
    $ret_array['max_lat'] = $starting_lat + $degrees;
    $ret_array['max_lon'] = $starting_lon + $degrees;
    $ret_array['min_lat'] = $starting_lat - $degrees;
    $ret_array['min_lon'] = $starting_lon - $degrees;

    return $ret_array;
  }

  /**
   * Allow users to set the name of the database table holding
   * the information.
   *
   * @access public
   * @param  string $table The name of the db table.
   * @return void
   */
  function setTableName($table) {
    $this->dbTable = $name;
  }

  /**
   * Allow users to set the name of the column holding the
   * latitude value.
   *
   * @access public
   * @param  string $lat The name of the column.
   * @return void
   */
  function setLatColumn($lat) {
    $this->dbLat = $lat;
  }

  /**
   * Allow users to set the name of the column holding the
   * longitude value.
   *
   * @access public
   * @param  string $lon The name of the column.
   * @return void
   */
  function setLonColumn($lon) {
    $this->dbLon = $lon;
  }

  /**
   * Allow users to set the name of the column holding the
   * zip code value.
   *
   * @access public
   * @param  string $zips The name of the column.
   * @return void
   */
  function setZipColumn($zip) {
    $this->dbZip = $zip;
  }

  /**
   * Set a new origin and re-get the data.
   *
   * @access public
   * @param  string $zip The new origin.
   * @return void
   */
  function setNewOrigin($zip) {
    
    if ($this->validateZipCode($zip)) {
      $this->zipCode = $zip;
      $this->setZipCodesInRange();
    }
  }
  
  /**
   * Set a new range and re-get the data.
   *
   * @access public
   * @param  float  $range The new range.
   * @return void
   */
  function setNewRange($range) {
    
    if (is_numeric($range)) {
      $this->range = $range;
      $this->setZipCodesInRange();
    }
  }

  /**
   * Set a new country but don't re-get the data.
   * 
   * It isn't any good to check a zip code in two 
   * countries cause the rules for zip codes vary from
   * country to country.
   *
   * @access public
   * @param  string $country The new country.
   * @return void
   */
  function setNewCountry($coutry) {

    if ($this->validateCountry($country)) {
      $this->country = $country;
    }
  }

  /**
   * Allow users to set the converstion ratio.
   * Hopefully you are changing the percision
   * and not setting a bad value.
   *
   * @access public
   * @param  float  $rate The new value.
   * @return void
   */
  function setConversionRate($rate) {
    
    if (is_numeric($rate)) {
      $this->milesToDegrees = $rate;
    }
  }
}
/* Debugging lines
$zcr = new ZipCodesRange(10965, 10);
print_r($zcr);
*/
?>
Return current item: Zip Codes Range