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