diff --git a/earth.inc b/earth.inc new file mode 100644 index 0000000..d9f72e7 --- /dev/null +++ b/earth.inc @@ -0,0 +1,184 @@ +, + * the creator of these routines, Ka-Ping Yee, authorized these routines to be + * distributed under the GPL. + */ + +/** + * @file + * Trigonometry for calculating geographical distances. + * All function arguments and return values measure distances in metres + * and angles in degrees. The ellipsoid model is from the WGS-84 datum. + * Ka-Ping Yee, 2003-08-11 + */ + +//$earth_radius_semimajor = 6378137.0; +//$earth_flattening = 1/298.257223563; +//$earth_radius_semiminor = $earth_radius_semimajor * (1 - $earth_flattening); +//$earth_eccentricity_sq = 2*$earth_flattening - pow($earth_flattening, 2); + +// I don't know what's up: PHP is hating on my global variables (commented out above), +// so I have to write functions that return them! (-Ankur) +// Commenting out the global variables above and replacing them with functions that +// return the same values is the only thing I changed since, for some reason, my +// PHP wasn't acknowledging these global variables. +// This library is an original implementation of UCB CS graduate student, Ka-Ping Yee (http://www.zesty.ca). + +function earth_radius_semimajor() { + return 6378137.0; +} + +function earth_flattening() { + return (1/298.257223563); +} + +function earth_radius_semiminor() { + return (earth_radius_semimajor() * (1 - earth_flattening())); +} + +function earth_eccentricity_sq() { + return (2*earth_flattening() - pow(earth_flattening(), 2)); +} + +// Latitudes in all of U. S.: from -7.2 (American Samoa) to 70.5 (Alaska). +// Latitudes in continental U. S.: from 24.6 (Florida) to 49.0 (Washington). +// Average latitude of all U. S. zipcodes: 37.9. + +function earth_radius($latitude=37.9) { + //global $earth_radius_semimajor, $earth_radius_semiminor; + // Estimate the Earth's radius at a given latitude. + // Default to an approximate average radius for the United States. + + $lat = deg2rad($latitude); + + $x = cos($lat)/earth_radius_semimajor(); + $y = sin($lat)/earth_radius_semiminor(); + return 1 / (sqrt($x*$x + $y*$y)); +} + +function earth_xyz($longitude, $latitude, $height = 0) { + // Convert longitude and latitude to earth-centered earth-fixed coordinates. + // X axis is 0 long, 0 lat; Y axis is 90 deg E; Z axis is north pole. + //global $earth_radius_semimajor, $earth_eccentricity_sq; + $long = deg2rad($longitude); + $lat = deg2rad($latitude); + + $coslong = cos($long); + $coslat = cos($lat); + $sinlong = sin($long); + $sinlat = sin($lat); + $radius = earth_radius_semimajor() / + sqrt(1 - earth_eccentricity_sq() * $sinlat * $sinlat); + $x = ($radius + $height) * $coslat * $coslong; + $y = ($radius + $height) * $coslat * $sinlong; + $z = ($radius * (1 - earth_eccentricity_sq()) + $height) * $sinlat; + return array($x, $y, $z); +} + +function earth_arclength($angle, $latitude=37.9) { + // Convert a given angle to earth-surface distance. + return deg2rad($angle) * earth_radius($latitude); +} + +function earth_distance($longitude1, $latitude1, $longitude2, $latitude2) { + // Estimate the earth-surface distance between two locations. + $long1 = deg2rad($longitude1); + $lat1 = deg2rad($latitude1); + $long2 = deg2rad($longitude2); + $lat2 = deg2rad($latitude2); + $radius = earth_radius(($latitude1 + $latitude2) / 2); + + $cosangle = cos($lat1)*cos($lat2) * + (cos($long1)*cos($long2) + sin($long1)*sin($long2)) + + sin($lat1)*sin($lat2); + return acos($cosangle) * $radius; +} + +/* + * Returns the SQL fragment needed to add a column called 'distance' + * to a query that includes the location table + * + * @param $longitude The measurement point + * @param $latibude The measurement point + * @param $tbl_alias If necessary, the alias name of the location table to work from. Only required when working with named {location} tables + */ +function earth_distance_sql($longitude, $latitude, $tbl_alias = '') { + // Make a SQL expression that estimates the distance to the given location. + $long = deg2rad($longitude); + $lat = deg2rad($latitude); + $radius = earth_radius($latitude); + + // If the table alias is specified, add on the separator. + $tbl_alias = empty($tbl_alias) ? $tbl_alias : ($tbl_alias .'.'); + + $coslong = cos($long); + $coslat = cos($lat); + $sinlong = sin($long); + $sinlat = sin($lat); + return "(IFNULL(ACOS($coslat*COS(RADIANS({$tbl_alias}latitude))*($coslong*COS(RADIANS({$tbl_alias}longitude)) + $sinlong*SIN(RADIANS({$tbl_alias}longitude))) + $sinlat*SIN(RADIANS({$tbl_alias}latitude))), 0.00000)*$radius)"; +} + +/** + * @todo This function uses earth_asin_safe so is not accurate for all possible + * parameter combinations. This means this function doesn't work properly + * for high distance values. This function needs to be re-written to work properly for + * larger distance values. See http://drupal.org/node/821628 + */ +function earth_longitude_range($longitude, $latitude, $distance) { + // Estimate the min and max longitudes within $distance of a given location. + $long = deg2rad($longitude); + $lat = deg2rad($latitude); + $radius = earth_radius($latitude); + + $angle = $distance / $radius; + $diff = earth_asin_safe(sin($angle)/cos($lat)); + $minlong = $long - $diff; + $maxlong = $long + $diff; + if ($minlong < -pi()) { $minlong = $minlong + pi()*2; } + if ($maxlong > pi()) { $maxlong = $maxlong - pi()*2; } + return array(rad2deg($minlong), rad2deg($maxlong)); +} + +function earth_latitude_range($longitude, $latitude, $distance) { + // Estimate the min and max latitudes within $distance of a given location. + $long = deg2rad($longitude); + $lat = deg2rad($latitude); + $radius = earth_radius($latitude); + + $angle = $distance / $radius; + $minlat = $lat - $angle; + $maxlat = $lat + $angle; + $rightangle = pi()/2; + if ($minlat < -$rightangle) { // wrapped around the south pole + $overshoot = -$minlat - $rightangle; + $minlat = -$rightangle + $overshoot; + if ($minlat > $maxlat) { $maxlat = $minlat; } + $minlat = -$rightangle; + } + if ($maxlat > $rightangle) { // wrapped around the north pole + $overshoot = $maxlat - $rightangle; + $maxlat = $rightangle - $overshoot; + if ($maxlat < $minlat) { $minlat = $maxlat; } + $maxlat = $rightangle; + } + return array(rad2deg($minlat), rad2deg($maxlat)); +} + +/** + * This is a helper function to avoid errors when using the asin() PHP function. + * asin is only real for values between -1 and 1. + * If a value outside that range is given it returns NAN (not a number), which + * we don't want to happen. + * So this just rounds values outside this range to -1 or 1 first. + * + * This means that calculations done using this function with $x outside the range + * will not be accurate. The alternative though is getting NAN, which is an error + * and won't give accurate results anyway. + */ +function earth_asin_safe($x) { + return asin(max(-1, min($x, 1))); +} diff --git a/geolocation_proximity.info b/geolocation_proximity.info index dfa1b95..973f472 100644 --- a/geolocation_proximity.info +++ b/geolocation_proximity.info @@ -8,3 +8,4 @@ dependencies[] = views files[] = handlers/geolocation_proximity_views_handler_filter_distance.inc files[] = handlers/geolocation_proximity_views_handler_field_distance.inc +files[] = handlers/geolocation_proximity_views_handler_argument_distance.inc diff --git a/geolocation_proximity.module b/geolocation_proximity.module index 058c191..4af5907 100644 --- a/geolocation_proximity.module +++ b/geolocation_proximity.module @@ -33,6 +33,9 @@ function geolocation_proximity_field_views_data_alter(&$result, $field, $module) // This is use by the table display plugin. 'click sortable' => TRUE, ), + 'argument' => array( + 'handler' => 'geolocation_proximity_views_handler_argument_distance', + ), ); } } @@ -55,6 +58,9 @@ function geolocation_proximity_views_data_alter(&$data) { $data[$field_name][$field['field_name']]['filter'] = array( 'handler' => 'geolocation_proximity_views_handler_filter_distance', ); + $data[$field_name][$field['field_name']]['argument'] = array( + 'handler' => 'geolocation_proximity_views_handler_argument_distance', + ); } return $data; } @@ -97,4 +103,4 @@ function _proximity_sql_fragment($filter_lat, $filter_lng, $field_latsin, $field * $field_latsin ) * $earth_radius )"; -} \ No newline at end of file +} diff --git a/handlers/geolocation_proximity_views_handler_argument_distance.inc b/handlers/geolocation_proximity_views_handler_argument_distance.inc new file mode 100644 index 0000000..bf030bd --- /dev/null +++ b/handlers/geolocation_proximity_views_handler_argument_distance.inc @@ -0,0 +1,353 @@ +field_alias = $this->options['id'] . '_argument'; // Removed: We can use $this->real_field; + } + + /** + * views_handler_filter::option_definition + */ + function option_definition() { + $options = parent::option_definition(); + $options['datasource'] = array('default' => 'geolocation', ); + $options['operator'] = array('default' => '<'); + // from location module: + $options['search_units'] = array('default' => 'km'); + $options['search_distance'] = array('default' => '100'); + $options['search_method'] = array('default' => 'mbr'); + $options['search_country'] = array('default' => variable_get('site_default_country', NULL)); + $options['type'] = array('default' => 'latlon'); + return $options; + } + + /** + * Display the filter on the administrative summary + */ + function admin_summary() { + return FALSE; + } + + /** + * Add a form elements to select options for this argument. + */ + function options_form(&$form, &$form_state) { + parent::options_form($form, $form_state); + + $form['type'] = array( + '#title' => t('Coordinate Type'), + '#type' => 'select', + '#options' => array( + 'postal' => t('Postal Code (Zipcode)'), + 'latlon' => t('Decimal Latitude and Longitude coordinates, comma delimited'), + ), + '#default_value' => $this->options['type'], + '#description' => t('Type of center point.') . '
' + . t('Postal code argument format (requires Geocoder module): country_postcode_distance or postcode_distance') . '
' + . t('Lat/Lon argument format: lat,lon_distance') . '
' + . t('where distance is either a number or a comma delimited pair of decimal degrees'), + ); + + // Source of lat/lon (taken from gmap.module) + $form['datasource'] = array( + '#type' => 'select', + '#title' => t('Data Source'), + '#options' => array( + 'location' => t('Location.module'), + 'geofield' => t('Geofield.module'), + 'geolocation' => t('Geolocation.module'), + //'fields' => t('Choose latitude and longitude fields'), + //'geocode' => t('Just-in-time geocoding on field named "address"'), + ), + '#default_value' => $this->options['datasource'], + '#multiple' => FALSE, + ); + + $form['latfield'] = array( + '#title' => t('Latitude field'), + '#description' => t('Format must be degrees decimal.'), + '#type' => 'select', + '#options' => $field_options, + '#default_value' => $this->options['latfield'], + '#process' => array('ctools_dependent_process'), + '#dependency' => array('edit-style-options-datasource' => array('fields')), + ); + + $form['lonfield'] = array( + '#title' => t('Longitude field'), + '#description' => t('Format must be degrees decimal.'), + '#type' => 'select', + '#options' => $field_options, + '#default_value' => $this->options['lonfield'], + '#process' => array('ctools_dependent_process'), + '#dependency' => array('edit-style-options-datasource' => array('fields')), + ); + + $form['geofield'] = array( + '#title' => t('Geofield field'), + '#description' => t('Select the Geofield source field.'), + '#type' => 'select', + '#options' => $field_options, + '#default_value' => $this->options['geofield'], + '#process' => array('ctools_dependent_process'), + '#dependency' => array('edit-style-options-datasource' => array('geofield')), + ); + + // Units used. + $form['search_units'] = array( + '#type' => 'select', + '#title' => t('Distance unit'), + '#options' => array( + 'km' => t('Kilometers'), + 'm' => t('Meters'), + 'mile' => t('Miles'), + 'dd' => t('Decimal degrees'), + ), + '#default_value' => $this->options['search_units'], + '#description' => t('Select the unit of distance. Decimal degrees should be comma delimited.'), + ); + + $form['search_method'] = array( + '#title' => t('Method'), + '#type' => 'select', + '#options' => array( + 'dist' => t('Circular Proximity'), + 'mbr' => t('Rectangular Proximity'), + ), + '#default_value' => $this->options['search_method'], + '#description' => t('Method of determining proximity. Please note that Circular Proximity does not work with Decimal degrees.'), + ); + + $form['search_country'] = array( + '#title' => t('Default country'), + '#type' => 'select', + '#options' => array( + 'none' => t('Determine from zipcode/location'), + 'default' => t('Default site country'), + ), + '#default_value' => $this->options['search_country'], + '#description' => t('For Postal Code (Zipcode) type, the method of determining the country, if not provided in argument.'), + ); + } + + /** + * Set up the query for this argument. + * + * The argument sent may be found at $this->argument. + */ + function query($group_by = FALSE) { + // Get and process argument. + $this->value = array(); + foreach ($this->view->argument as $argument) { + if ($argument->field == 'field_geolocation_distance') { + if ($this->options['type'] == 'latlon') { + // Argument like '50.91,4.43_50' (Brussels area) + $arg_parts = explode('_', $this->view->args[$argument->position]); + list($coords, $this->value['search_distance']) = $arg_parts; + list($this->value['latitude'], $this->value['longitude']) = explode(',', $coords); + } + elseif ($this->options['type'] == 'postal') { + if (module_exists('geocoder')) { + include_once(DRUPAL_ROOT . '/' . drupal_get_path('module', 'geocoder') . '/plugins/geocoder_handler/google.inc'); + } + else { + watchdog(WATCHDOG_ERROR, t('The Geocoder module must be installed to use this handler.')); + return; + } + + $arg_parts = explode('_', $this->view->args[$argument->position]); + if (count($arg_parts) == 3) { + // Argument like 'BE_Brussels_50' + $this->value['country'] = drupal_strtolower($arg_parts[0]); + $this->value['postal_code'] = $arg_parts[1]; + $this->value['search_distance'] = $arg_parts[2]; + $google_address = $this->value['postal_code'] . ', ' . $this->value['country']; + } + else { + // Argument like 'Brussels_50' + $this->value['postal_code'] = $arg_parts[0]; + $this->value['search_distance'] = $arg_parts[1]; + $google_address = $this->value['postal_code']; + } + + $google_point = geocoder_google($google_address, array('address' => $google_address)); + if ($google_point) { + $this->value['latitude'] = $google_point->coords[1]; + $this->value['longitude'] = $google_point->coords[0]; + } + } + break; + } + } + + // Coordinates available? + if (empty($this->value['latitude']) ||empty($this->value['longitude'])) { + // Distance set? + if (!empty($this->value['search_distance'])) { + // Hmm, distance set but unable to resolve coordinates. + // Force nothing to match. + $this->query->add_where(0, "1 = 0"); + } + return; + } + $lat = $this->value['latitude']; + $lon = $this->value['longitude']; + // search_distance + if ($this->options['search_units'] == 'dd') { + list($lat_distance, $lon_distance) = explode(',', $this->value['search_distance']); + $latrange[0] = $lat - $lat_distance; + $latrange[1] = $lat + $lat_distance; + $lonrange[0] = $lon - $lon_distance; + $lonrange[1] = $lon + $lon_distance; + } + else { + $this->load_location_files(); + $distance = $this->value['search_distance']; + $distance_meters = $this->_convert_to_meters($distance, $this->options['search_units']); + + $latrange = earth_latitude_range($lon, $lat, $distance_meters); + $lonrange = earth_longitude_range($lon, $lat, $distance_meters); + } + + // Prepare filter values. + $this->ensure_my_table(); + $table = $this->table_alias; + $field_id = str_replace('_distance', '', $this->options['field']); // Get the base part of the subfields. + + // Provide support other field types. TODO: create a 'field type' option. + switch ($argument->field) { + case 'field_geolocation_distance': + $field_lat = "{$table}.{$field_id}_lat"; + $field_lng = "{$table}.{$field_id}_lng"; + $field_latsin = "{$table}.{$field_id}_lat_sin"; + $field_latcos = "{$table}.{$field_id}_lat_cos"; + $field_lngrad = "{$table}.{$field_id}_lng_rad"; + break; + case 'field_location': // supports location.module + $field_id = ''; + $field_lat = "{$table}.latitude"; + $field_lng = "{$table}.longitude"; + break; + case 'field_geofield': // supports geofield.module + $field_lat = "{$table}.{$field_id}_lat"; + $field_lng = "{$table}.{$field_id}_lon"; + break; + } + + // Build the query. (location.module style) + // Add MBR check (always). + // In case we go past the 180/-180 mark for longitude. + if ($lonrange[0] > $lonrange[1]) { + $where = "$field_lat > :minlat AND $field_lat < :maxlat AND (($field_lat < 180 AND $field_lat > :minlon) OR ($field_lng < :maxlon AND $field_lat > -180))"; + } + else { + $where = "$field_lat > :minlat AND $field_lat < :maxlat AND $field_lng > :minlon AND $field_lng < :maxlon"; + } + $this->query->add_where_expression(0, $where, array(':minlat' => $latrange[0], ':maxlat' => $latrange[1], ':minlon' => $lonrange[0], ':maxlon' => $lonrange[1])); + if ($this->options['search_method'] == 'dist') { + // Add radius check. + $this->query->add_where_expression(0, earth_distance_sql($lon, $lat, $this->table_alias) . ' < :distance', array(':distance' => $distance_meters)); + } + +// // Build the query. (geolocation.module style) +// $sql = _proximity_sql_fragment($lat, $lon, $field_latsin, $field_latcos, $field_lngrad); +// // We use having to be able to reuse the query on field handlers +//// $this->query->add_field(NULL, $sql, $this->field_alias); +// $this->query->add_field(NULL, $sql, $this->real_field); +// $this->query->add_having($this->options['group'], $field_id, $distance_meters, $this->operator); + } + + function load_location_files() { + // Include the external libraries from location.module + // They are duplicated in geolocation_proximity module directory. + // Therefore, avoid loading the files twice, by first checking the location module, then this module. + $found = false; + $file = DRUPAL_ROOT . '/' . drupal_get_path('module', 'location') . '/earth.inc'; + if (file_exists($file)) { // Read the country file. + $found = include_once($file); + } + if(!$found) { + $file = DRUPAL_ROOT . '/' . drupal_get_path('module', 'geolocation_proximity') . '/earth.inc'; + $found = include_once($file); + } + } + + /** + * Validate the options form. + */ +// function value_validate($form, &$form_state) { +// $this->_latlng_validate($form['value'], $form_state['values']['options']['value']); +// } +// +// function exposed_validate(&$form, &$form_state) { +// $this->_latlng_validate($form[$this->options['id']], $form_state['values'][$this->options['id']]); +// } + + /** + * Validate the latitude and longitude values + */ +/* + function _latlng_validate(&$elements, &$values) { + switch (TRUE) { + case !is_numeric($values['latitude']): + form_error($elements['latitude'], t('Invalid Latitude. Value must be numeric.')); + break; + + case $values['latitude'] > 90: + case $values['latitude'] < -90: + form_error($elements['latitude'], t('Invalid Latitude. Value must be between 90 and -90.')); + break; + } + + switch (TRUE) { + case !is_numeric($values['longitude']): + form_error($elements['longitude'], t('Invalid Longitude. Value must be numeric.')); + break; + + case $values['longitude'] > 180: + case $values['longitude'] < -180: + form_error($elements['longitude'], t('Invalid Longitude. Value must be between 180 and -180.')); + break; + } + + if (!is_numeric($values['search_distance']) || $values['search_distance'] < 0) { + form_error($elements['search_distance'], t('Invalid Distance. Value must be a positive number.')); + } + } +*/ + + function _convert_to_meters($distance, $distance_unit = 'km') { + if (!is_numeric($distance)) { + return NULL; + } + + if ($distance == 0) { + return NULL; + } + + if ($distance_unit == 'm') { + return $distance; + } + + if ($distance_unit != 'km' && $distance_unit != 'mile') { + $distance_unit = 'km'; + } + + // Convert distance to meters + $retval = round(floatval($distance) * (($distance_unit == 'km') ? 1000.0 : 1609.347), 2); + return $retval; + } +}