Merge pull request #1373 from ravishnair/CRM-13037
[civicrm-core.git] / CRM / Contact / BAO / ProximityQuery.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.3 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2013 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26 */
27
28 /**
29 *
30 * @package CRM
31 * @copyright CiviCRM LLC (c) 2004-2013
32 * $Id$
33 *
34 */
35 class CRM_Contact_BAO_ProximityQuery {
36
37 /**
38 * Trigonometry for calculating geographical distances.
39 * All function arguments and return values measure distances in metres
40 * and angles in degrees. The ellipsoid model is from the WGS-84 datum.
41 * Ka-Ping Yee, 2003-08-11
42
43 * earth_radius_semimajor = 6378137.0;
44 * earth_flattening = 1/298.257223563;
45 * earth_radius_semiminor = $earth_radius_semimajor * (1 - $earth_flattening);
46 * earth_eccentricity_sq = 2*$earth_flattening - pow($earth_flattening, 2);
47
48 * This library is an implementation of UCB CS graduate student, Ka-Ping Yee (http://www.zesty.ca).
49 * This version has been taken from Drupal's location module: http://drupal.org/project/location
50 **/
51
52 static protected $_earthFlattening;
53 static protected $_earthRadiusSemiMinor;
54 static protected $_earthRadiusSemiMajor;
55 static protected $_earthEccentricitySQ;
56
57 static function initialize() {
58 static $_initialized = FALSE;
59
60 if (!$_initialized) {
61 $_initialized = TRUE;
62
63 self::$_earthFlattening = 1.0 / 298.257223563;
64 self::$_earthRadiusSemiMajor = 6378137.0;
65 self::$_earthRadiusSemiMinor = self::$_earthRadiusSemiMajor * (1.0 - self::$_earthFlattening);
66 self::$_earthEccentricitySQ = 2 * self::$_earthFlattening - pow(self::$_earthFlattening, 2);
67 }
68 }
69
70 /**
71 * Latitudes in all of U. S.: from -7.2 (American Samoa) to 70.5 (Alaska).
72 * Latitudes in continental U. S.: from 24.6 (Florida) to 49.0 (Washington).
73 * Average latitude of all U. S. zipcodes: 37.9.
74 */
75
76 /*
77 /**
78 * Estimate the Earth's radius at a given latitude.
79 * Default to an approximate average radius for the United States.
80 */
81
82 static function earthRadius($latitude) {
83 $lat = deg2rad($latitude);
84
85 $x = cos($lat) / self::$_earthRadiusSemiMajor;
86 $y = sin($lat) / self::$_earthRadiusSemiMinor;
87 return 1.0 / sqrt($x * $x + $y * $y);
88 }
89
90 /**
91 * Convert longitude and latitude to earth-centered earth-fixed coordinates.
92 * X axis is 0 long, 0 lat; Y axis is 90 deg E; Z axis is north pole.
93 */
94 static function earthXYZ($longitude, $latitude, $height = 0) {
95 $long = deg2rad($longitude);
96 $lat = deg2rad($latitude);
97
98 $cosLong = cos($long);
99 $cosLat = cos($lat);
100 $sinLong = sin($long);
101 $sinLat = sin($lat);
102
103 $radius = self::$_earthRadiusSemiMajor / sqrt(1 - self::$_earthEccentricitySQ * $sinLat * $sinLat);
104
105 $x = ($radius + $height) * $cosLat * $cosLong;
106 $y = ($radius + $height) * $cosLat * $sinLong;
107 $z = ($radius * (1 - self::$_earthEccentricitySQ) + $height) * $sinLat;
108
109 return array($x, $y, $z);
110 }
111
112 /**
113 * Convert a given angle to earth-surface distance.
114 */
115 static function earthArcLength($angle, $latitude) {
116 return deg2rad($angle) * self::earthRadius($latitude);
117 }
118
119 /**
120 * Estimate the earth-surface distance between two locations.
121 */
122 static function earthDistance($longitudeSrc, $latitudeSrc,
123 $longitudeDst, $latitudeDst
124 ) {
125
126 $longSrc = deg2rad($longitudeSrc);
127 $latSrc = deg2rad($latitudeSrc);
128 $longDst = deg2rad($longitudeDst);
129 $latDst = deg2rad($latitudeDst);
130
131 $radius = self::earthRadius(($latitudeSrc + $latitudeDst) / 2);
132
133 $cosAngle = cos($latSrc) * cos($latDst) * (cos($longSrc) * cos($longDst) + sin($longSrc) * sin($longDst)) + sin($latSrc) * sin($latDst);
134 return acos($cosAngle) * $radius;
135 }
136
137 /**
138 * Estimate the min and max longitudes within $distance of a given location.
139 */
140 static function earthLongitudeRange($longitude, $latitude, $distance) {
141 $long = deg2rad($longitude);
142 $lat = deg2rad($latitude);
143 $radius = self::earthRadius($latitude);
144
145 $angle = $distance / $radius;
146 $diff = asin(sin($angle) / cos($lat));
147 $minLong = $long - $diff;
148 $maxLong = $long + $diff;
149
150 if ($minLong < - pi()) {
151 $minLong = $minLong + pi() * 2;
152 }
153
154 if ($maxLong > pi()) {
155 $maxLong = $maxLong - pi() * 2;
156 }
157
158 return array(rad2deg($minLong),
159 rad2deg($maxLong),
160 );
161 }
162
163 /**
164 * Estimate the min and max latitudes within $distance of a given location.
165 */
166 static function earthLatitudeRange($longitude, $latitude, $distance) {
167 $long = deg2rad($longitude);
168 $lat = deg2rad($latitude);
169 $radius = self::earthRadius($latitude);
170
171 $angle = $distance / $radius;
172 $minLat = $lat - $angle;
173 $maxLat = $lat + $angle;
174 $rightangle = pi() / 2.0;
175
176 // wrapped around the south pole
177 if ($minLat < - $rightangle) {
178 $overshoot = -$minLat - $rightangle;
179 $minLat = -$rightangle + $overshoot;
180 if ($minLat > $maxLat) {
181 $maxLat = $minLat;
182 }
183 $minLat = -$rightangle;
184 }
185
186 // wrapped around the north pole
187 if ($maxLat > $rightangle) {
188 $overshoot = $maxLat - $rightangle;
189 $maxLat = $rightangle - $overshoot;
190 if ($maxLat < $minLat) {
191 $minLat = $maxLat;
192 }
193 $maxLat = $rightangle;
194 }
195
196 return array(rad2deg($minLat),
197 rad2deg($maxLat),
198 );
199 }
200
201 /*
202 * Returns the SQL fragment needed to add a column called 'distance'
203 * to a query that includes the location table
204 *
205 * @param $longitude
206 * @param $latitude
207 */
208
209 static function earthDistanceSQL($longitude, $latitude) {
210 $long = deg2rad($longitude);
211 $lat = deg2rad($latitude);
212 $radius = self::earthRadius($latitude);
213
214 $cosLong = cos($long);
215 $cosLat = cos($lat);
216 $sinLong = sin($long);
217 $sinLat = sin($lat);
218
219 return "
220 IFNULL( ACOS( $cosLat * COS( RADIANS( $latitude ) ) *
221 ( $cosLong * COS( RADIANS( $longitude ) ) +
222 $sinLong * SIN( RADIANS( $longitude ) ) ) +
223 $sinLat * SIN( RADIANS( $latitude ) ) ), 0.00000 ) * $radius
224 ";
225 }
226
227 static function where($latitude, $longitude, $distance, $tablePrefix = 'civicrm_address') {
228 self::initialize();
229
230 $params = array();
231 $clause = array();
232
233 list($minLongitude, $maxLongitude) = self::earthLongitudeRange($longitude,
234 $latitude,
235 $distance
236 );
237 list($minLatitude, $maxLatitude) = self::earthLatitudeRange($longitude,
238 $latitude,
239 $distance
240 );
241
242 $earthDistanceSQL = self::earthDistanceSQL($longitude, $latitude);
243
244 $where = "
245 {$tablePrefix}.geo_code_1 >= $minLatitude AND
246 {$tablePrefix}.geo_code_1 <= $maxLatitude AND
247 {$tablePrefix}.geo_code_2 >= $minLongitude AND
248 {$tablePrefix}.geo_code_2 <= $maxLongitude AND
249 $earthDistanceSQL <= $distance
250 ";
251
252 return $where;
253 }
254
255 static function process(&$query, &$values) {
256 list($name, $op, $distance, $grouping, $wildcard) = $values;
257
258 // also get values array for all address related info
259 $proximityVars = array(
260 'street_address' => 1,
261 'city' => 1,
262 'postal_code' => 1,
263 'state_province_id' => 0,
264 'country_id' => 0,
265 'state_province' => 0,
266 'country' => 0,
267 'distance_unit' => 0,
268 );
269
270 $proximityAddress = array();
271 $qill = array();
272 foreach ($proximityVars as $var => $recordQill) {
273 $proximityValues = $query->getWhereValues("prox_{$var}", $grouping);
274 if (!empty($proximityValues) &&
275 !empty($proximityValues[2])
276 ) {
277 $proximityAddress[$var] = $proximityValues[2];
278 if ($recordQill) {
279 $qill[] = $proximityValues[2];
280 }
281 }
282 }
283
284 if (empty($proximityAddress)) {
285 return;
286 }
287
288 if (isset($proximityAddress['state_province_id'])) {
289 $proximityAddress['state_province'] = CRM_Core_PseudoConstant::stateProvince($proximityAddress['state_province_id']);
290 $qill[] = $proximityAddress['state_province'];
291 }
292
293 $config = CRM_Core_Config::singleton();
294 if (!isset($proximityAddress['country_id'])) {
295 // get it from state if state is present
296 if (isset($proximityAddress['state_province_id'])) {
297 $proximityAddress['country_id'] = CRM_Core_PseudoConstant::countryForState($proximityAddress['state_province_id']);
298 }
299 elseif (isset($config->defaultContactCountry)) {
300 $proximityAddress['country_id'] = $config->defaultContactCountry;
301 }
302 }
303
304 if (isset($proximityAddress['country_id'])) {
305 $proximityAddress['country'] = CRM_Core_PseudoConstant::country($proximityAddress['country_id']);
306 $qill[] = $proximityAddress['country'];
307 }
308
309
310 if (
311 isset($proximityAddress['distance_unit']) &&
312 $proximityAddress['distance_unit'] == 'miles'
313 ) {
314 $qillUnits = " {$distance} " . ts('miles');
315 $distance = $distance * 1609.344;
316 }
317 else {
318 $qillUnits = " {$distance} " . ts('km');
319 $distance = $distance * 1000.00;
320 }
321
322 $qill = ts('Proximity search to a distance of %1 from %2',
323 array(
324 1 => $qillUnits,
325 2 => implode(', ', $qill)
326 )
327 );
328
329 $fnName = isset($config->geocodeMethod) ? $config->geocodeMethod : NULL;
330 if (empty($fnName)) {
331 CRM_Core_Error::fatal(ts('Proximity searching requires you to set a valid geocoding provider'));
332 }
333
334 $query->_tables['civicrm_address'] = $query->_whereTables['civicrm_address'] = 1;
335
336 require_once (str_replace('_', DIRECTORY_SEPARATOR, $fnName) . '.php');
337 $fnName::format($proximityAddress);
338 if (
339 !is_numeric(CRM_Utils_Array::value('geo_code_1', $proximityAddress)) ||
340 !is_numeric(CRM_Utils_Array::value('geo_code_2', $proximityAddress))
341 ) {
342 // we are setting the where clause to 0 here, so we wont return anything
343 $qill .= ': ' . ts('We could not geocode the destination address.');
344 $query->_qill[$grouping][] = $qill;
345 $query->_where[$grouping][] = ' (0) ';
346 return;
347 }
348
349 $query->_qill[$grouping][] = $qill;
350 $query->_where[$grouping][] = self::where(
351 $proximityAddress['geo_code_1'],
352 $proximityAddress['geo_code_2'],
353 $distance
354 );
355
356 return;
357 }
358
359 static function fixInputParams(&$input) {
360 foreach ($input as $param) {
361 if (CRM_Utils_Array::value('0', $param) == 'prox_distance') {
362 // add prox_ prefix to these
363 $param_alter = array('street_address', 'city', 'postal_code', 'state_province', 'country');
364
365 foreach ($input as $key => $_param) {
366 if (in_array($_param[0], $param_alter)) {
367 $input[$key][0] = 'prox_' . $_param[0];
368
369 // _id suffix where needed
370 if ($_param[0] == 'country' || $_param[0] == 'state_province') {
371 $input[$key][0] .= '_id';
372
373 // flatten state_province array
374 if (is_array($input[$key][2])) {
375 $input[$key][2] = $input[$key][2][0];
376 }
377 }
378 }
379 }
380 return;
381 }
382 }
383 }
384 }
385