true, 'cake' => true, 'php' => true ); public $units = array( self::UNIT_KM => 1.609344, self::UNIT_NAUTICAL => 0.868976242, self::UNIT_FEET => 5280, self::UNIT_INCHES => 63360, self::UNIT_MILES => 1 ); /** * Validation and retrieval options * - use: * - log: false logs only real errors, true all activities * - pause: timeout to prevent blocking * - ... * */ public $options = array( 'log' => false, 'pause' => 10000, # in ms 'min_accuracy' => self::ACC_COUNTRY, 'allow_inconclusive' => true, 'expect' => array(), # see accuracyTypes for details // static url params 'output' => 'json', 'host' => null, # results in maps.google.com - use if you wish to obtain the closest address ); /** * Url params */ protected $params = array( 'address' => '', # either address or latlng required! 'latlng' => '', # The textual latitude/longitude value for which you wish to obtain the closest, human-readable address 'region' => '', # The region code, specified as a ccTLD ("top-level domain") two-character 'language' => 'de', 'bounds' => '', 'sensor' => 'false', # device with gps module sensor //'key' => '' # not necessary anymore ); protected $error = array(); protected $debug = array(); protected $result = null; protected $statusCodes = array( self::CODE_SUCCESS => 'Success', self::CODE_BAD_REQUEST => 'Sensor param missing', self::CODE_MISSING_QUERY => 'Adress/LatLng missing', self::CODE_UNKNOWN_ADDRESS => 'Success, but to address found', self::CODE_TOO_MANY_QUERIES => 'Limit exceeded', ); protected $accuracyTypes = array( self::ACC_COUNTRY => 'country', self::ACC_AAL1 => 'administrative_area_level_1', # provinces/states self::ACC_AAL2 => 'administrative_area_level_2 ', self::ACC_AAL3 => 'administrative_area_level_3', self::ACC_POSTAL => 'postal_code', self::ACC_LOC => 'locality', self::ACC_SUBLOC => 'sublocality', self::ACC_ROUTE => 'route', self::ACC_INTERSEC => 'intersection', self::ACC_STREET => 'street_address' //neighborhood premise subpremise natural_feature airport park point_of_interest colloquial_area political ? ); public function __construct($options = array()) { $this->defaultParams = $this->params; $this->defaultOptions = $this->options; if (Configure::read('debug') > 0) { $this->options['log'] = true; } $this->setOptions($options); if (empty($this->options['host'])) { $this->options['host'] = self::DEFAULT_HOST; } } /** * @param array $params * @return void */ public function setParams($params) { foreach ($params as $key => $value) { if ($key === 'sensor' && $value !== 'false' && $value !== 'true') { $value = !empty($value) ? 'true' : 'false'; } $this->params[$key] = urlencode((string)$value); } } /** * @param array $options * @return void */ public function setOptions($options) { foreach ($options as $key => $value) { if ($key === 'output' && $value !== 'xml' && $value !== 'json') { throw new CakeException('Invalid output format'); } $this->options[$key] = $value; } } public function setError($error) { if (empty($error)) { return; } $this->debugSet('setError', $error); $this->error[] = $error; } public function error($asString = true, $separator = ', ') { if (!$asString) { return $this->error; } return implode(', ', $this->error); } /** * Reset - ready for the next request * * @param mixed boolean $full or string === 'params' to reset just params * @return void */ public function reset($full = true) { $this->error = array(); $this->result = null; if (empty($full)) { return; } if ($full === 'params') { $this->params = $this->defaultParams; return; } $this->params = $this->defaultParams; $this->options = $this->defaultOptions; } /** * Build url * * @return string url (full) */ public function url() { $params = array( 'host' => $this->options['host'], 'output' => $this->options['output'] ); $url = String::insert(self::BASE_URL, $params, array('before' => '{', 'after' => '}', 'clean' => true)); $params = array(); foreach ($this->params as $key => $value) { if (!empty($value)) { $params[] = $key . '=' . $value; } } return $url . implode('&', $params); } /** * @return boolean isInconclusive (or null if no query has been run yet) */ public function isInconclusive() { if ($this->result === null) { return null; } if (array_key_exists('location_type', $this->result) && !empty($this->result['location_type'])) { return true; } return false; } /** * Return the geocoder result or empty array on failure * * @return array result */ public function getResult() { if ($this->result === null) { return array(); } if (is_string($this->result)) { return $this->_transform($this->result); } if (!is_array($this->result)) { return array(); } return $this->result; } /** * Results usually from most accurate to least accurate result (street_address, ..., country) * * @param float $lat * @param float $lng * @param array $params * - allow_inconclusive * - min_accuracy * @return boolean Success */ public function reverseGeocode($lat, $lng, $params = array()) { $this->reset(false); $this->debugSet('reverseGeocode', compact('lat', 'lng', 'params')); $latlng = $lat . ',' . $lng; $this->setParams(array_merge($params, array('latlng' => $latlng))); $count = 0; $requestUrl = $this->url(); while (true) { $result = $this->_fetch($requestUrl); if ($result === false || $result === null) { $this->setError('Could not retrieve url'); CakeLog::write('geocode', __('Could not retrieve url with \'%s\'', $latlng)); return false; } $this->debugSet('raw', $result); $result = $this->_transform($result); if (!is_array($result)) { $this->setError('Result parsing failed'); CakeLog::write('geocode', __('Failed reverseGeocode parsing of \'%s\'', $latlng)); return false; } $status = $result['status']; if ($status == self::CODE_SUCCESS) { // validate if (isset($result['results'][0]) && !$this->options['allow_inconclusive']) { $this->setError(__('Inconclusive result (total of %s)', count($result['results']))); $this->result = $result['results']; return false; } if (isset($result['results'][0])) { $result['result'] = $result['results'][0]; } $accuracy = $this->_getMaxAccuracy($result['result']); if ($this->_isNotAccurateEnough($accuracy)) { $accuracy = $this->accuracyTypes[$accuracy]; $minAccuracy = $this->accuracyTypes[$this->options['min_accuracy']]; $this->setError(__('Accuracy not good enough (%s instead of at least %s)', $accuracy, $minAccuracy)); $this->result = $result['result']; return false; } // save Result if ($this->options['log']) { CakeLog::write('geocode', __('Address \'%s\' has been geocoded', $latlng)); } break; } elseif ($status == self::CODE_TOO_MANY_QUERIES) { // sent geocodes too fast, delay +0.1 seconds if ($this->options['log']) { CakeLog::write('geocode', __('Delay necessary for \'%s\'', $latlng)); } $count++; } else { // something went wrong $this->setError('Error ' . $status . (isset($this->statusCodes[$status]) ? ' (' . $this->statusCodes[$status] . ')' : '')); if ($this->options['log']) { CakeLog::write('geocode', __('Could not geocode \'%s\'', $latlng)); } return false; # for now... } if ($count > 5) { if ($this->options['log']) { CakeLog::write('geocode', __('Aborted after too many trials with \'%s\'', $latlng)); } $this->setError(__('Too many trials - abort')); return false; } $this->pause(true); } $this->result = $result['result']; return true; } /** * Trying to avoid "TOO_MANY_QUERIES" error * @param boolean $raise If the pause length should be raised */ public function pause($raise = false) { usleep($this->options['pause']); if ($raise) { $this->options['pause'] += 10000; } } /** * Actual querying * * @param string $address * @param array $params * @return boolean Success */ public function geocode($address, $params = array()) { $this->reset(false); $this->debugSet('reverseGeocode', compact('address', 'params')); $this->setParams(array_merge($params, array('address' => $address))); if ($this->options['allow_inconclusive']) { // only host working with this setting? //$this->options['host'] = self::DEFAULT_HOST; } $count = 0; $requestUrl = $this->url(); while (true) { $result = $this->_fetch($requestUrl); if ($result === false || $result === null) { $this->setError('Could not retrieve url'); CakeLog::write('geocode', 'Geocoder could not retrieve url with \'' . $address . '\''); return false; } $this->debugSet('raw', $result); $result = $this->_transform($result); if (!is_array($result)) { $this->setError('Result parsing failed'); CakeLog::write('geocode', __('Failed geocode parsing of \'%s\'', $address)); return false; } $status = $result['status']; //debug(compact('result', 'requestUrl', 'success')); if ($status == self::CODE_SUCCESS) { // validate if (isset($result['results'][0]) && !$this->options['allow_inconclusive']) { $this->setError(__('Inconclusive result (total of %s)', count($result['results']))); $this->result = $result['results']; return false; } if (isset($result['results'][0])) { $result['result'] = $result['results'][0]; } $accuracy = $this->_getMaxAccuracy($result['result']); if ($this->_isNotAccurateEnough($accuracy)) { $accuracyText = $this->accuracyTypes[$accuracy]; $minAccuracy = $this->accuracyTypes[$this->options['min_accuracy']]; $this->setError(__('Accuracy not good enough (%s instead of at least %s)', $accuracyText, $minAccuracy)); $this->result = $result['result']; return false; } if (!empty($this->options['expect'])) { $fields = (empty($result['result']['types']) ? array() : Hash::filter($result['result']['types'])); $found = array_intersect($fields, (array)$this->options['expect']); $validExpectation = !empty($found); if (!$validExpectation) { $this->setError(__('Expectation not reached (we have %s instead of at least %s)', implode(', ', $found), implode(', ', (array)$this->options['expect']) )); $this->result = $result['result']; return false; } } // save Result if ($this->options['log']) { CakeLog::write('geocode', __('Address \'%s\' has been geocoded', $address)); } break; } elseif ($status == self::CODE_TOO_MANY_QUERIES) { // sent geocodes too fast, delay +0.1 seconds if ($this->options['log']) { CakeLog::write('geocode', __('Delay necessary for address \'%s\'', $address)); } $count++; } else { // something went wrong $errorMessage = (isset($result['error_message']) ? $result['error_message'] : ''); if (empty($errorMessage)) { $errorMessage = (isset($this->statusCodes[$status]) ? $this->statusCodes[$status] : ''); } if (empty($errorMessage)) { $errorMessage = 'unknown'; } $this->setError('Error ' . $status . ' (' . $errorMessage . ')'); if ($this->options['log']) { CakeLog::write('geocode', __('Could not geocode \'%s\'', $address)); } return false; # for now... } if ($count > 5) { if ($this->options['log']) { CakeLog::write('geocode', __('Aborted after too many trials with \'%s\'', $address)); } $this->setError('Too many trials - abort'); return false; } $this->pause(true); } $this->result = $result['result']; $this->result['all_results'] = $result['results']; return true; } /** * GeocodeLib::accuracyTypes() * * @param mixed $value * @return mixed Type or types */ public function accuracyTypes($value = null) { if ($value !== null) { if (isset($this->accuracyTypes[$value])) { return $this->accuracyTypes[$value]; } return null; } return $this->accuracyTypes; } /** * @return boolean $notAccurateEnough */ protected function _isNotAccurateEnough($accuracy = null) { if (is_array($accuracy)) { $accuracy = $this->_getMaxAccuracy($accuracy); } if (empty($accuracy)) { $accuracy = 0; } // did we get a value instead of a key? if (in_array($accuracy, $this->accuracyTypes, true)) { $accuracy = array_search($accuracy, $this->accuracyTypes); } // validate key exists if (!array_key_exists($accuracy, $this->accuracyTypes)) { $accuracy = 0; } // is our current accuracy < minimum? return $accuracy < $this->options['min_accuracy']; } /** * GeocodeLib::_transform() * * @param string $record * @return array */ protected function _transform($record) { if ($this->options['output'] === 'json') { return $this->_transformJson($record); } return $this->_transformXml($record); } /** * GeocodeLib::_transformJson() * * @param string $record * @return array */ protected function _transformJson($record) { if (!is_array($record)) { $record = json_decode($record, true); } return $this->_transformData($record); } /** * @return array * @deprecated */ protected function _transformXml($record) { trigger_error('deprecated, use json instead', E_USER_DEPRECATED); if (!is_array($record)) { $xml = Xml::build($record); $record = Xml::toArray($xml); if (array_key_exists('GeocodeResponse', $record)) { $record = $record['GeocodeResponse']; } } return $this->_transformData($record); } /** * Try to find the max accuracy level * - look through all fields and * attempt to find the first record which matches an accuracyTypes field * * @param array $record * @return int $maxAccuracy 9-0 as defined in $this->accuracyTypes */ public function _getMaxAccuracy($record) { if (!is_array($record)) { return null; } $accuracyTypes = $this->accuracyTypes; $accuracyTypes = array_reverse($accuracyTypes, true); foreach ($accuracyTypes as $key => $field) { if (array_key_exists($field, $record) && !empty($record[$field])) { // found $field -- return it's $key return $key; } } // not found? recurse into all possible children foreach (array_keys($record) as $key) { if (empty($record[$key]) || !is_array($record[$key])) { continue; } $accuracy = $this->_getMaxAccuracy($record[$key]); if ($accuracy !== null) { // found in nested value return $accuracy; } } return null; } /** * Flattens result array and returns clean record * keys: * - formatted_address, type, country, country_code, country_province, country_province_code, locality, sublocality, postal_code, route, lat, lng, location_type, viewport, bounds * * @param mixed $record any level of input, whole raw array or records or single record * @return array $record organized & normalized */ protected function _transformData($record) { if (!is_array($record)) { return $record; } if (!array_key_exists('address_components', $record)) { foreach (array_keys($record) as $key) { $record[$key] = $this->_transformData($record[$key]); } return $record; } $res = array(); // handle and organize address_components $components = array(); if (!isset($record['address_components'][0])) { $record['address_components'] = array($record['address_components']); } foreach ($record['address_components'] as $c) { $types = array(); if (isset($c['types'])) { //!is_array($c['Type']) if (!is_array($c['types'])) { $c['types'] = (array)$c['types']; } $type = $c['types'][0]; array_shift($c['types']); $types = $c['types']; } elseif (isset($c['types'])) { $type = $c['types']; } else { // error? continue; } if (array_key_exists($type, $components)) { $components[$type]['name'] .= ' ' . $c['long_name']; $components[$type]['abbr'] .= ' ' . $c['short_name']; $components[$type]['types'] += $types; } $components[$type] = array('name' => $c['long_name'], 'abbr' => $c['short_name'], 'types' => $types); } $res['formatted_address'] = $record['formatted_address']; if (array_key_exists('country', $components)) { $res['country'] = $components['country']['name']; $res['country_code'] = $components['country']['abbr']; } else { $res['country'] = $res['country_code'] = ''; } if (array_key_exists('administrative_area_level_1', $components)) { $res['country_province'] = $components['administrative_area_level_1']['name']; $res['country_province_code'] = $components['administrative_area_level_1']['abbr']; } else { $res['country_province'] = $res['country_province_code'] = ''; } if (array_key_exists('postal_code', $components)) { $res['postal_code'] = $components['postal_code']['name']; } else { $res['postal_code'] = ''; } if (array_key_exists('locality', $components)) { $res['locality'] = $components['locality']['name']; } else { $res['locality'] = ''; } if (array_key_exists('sublocality', $components)) { $res['sublocality'] = $components['sublocality']['name']; } else { $res['sublocality'] = ''; } if (array_key_exists('route', $components)) { $res['route'] = $components['route']['name']; if (array_key_exists('street_number', $components)) { $res['route'] .= ' ' . $components['street_number']['name']; } } else { $res['route'] = ''; } // determine accuracy types if (array_key_exists('types', $record)) { $res['types'] = $record['types']; } else { $res['types'] = array(); } //TODO: add more $res['lat'] = $record['geometry']['location']['lat']; $res['lng'] = $record['geometry']['location']['lng']; $res['location_type'] = $record['geometry']['location_type']; if (!empty($record['geometry']['viewport'])) { $res['viewport'] = array('sw' => $record['geometry']['viewport']['southwest'], 'ne' => $record['geometry']['viewport']['northeast']); } if (!empty($record['geometry']['bounds'])) { $res['bounds'] = array('sw' => $record['geometry']['bounds']['southwest'], 'ne' => $record['geometry']['bounds']['northeast']); } // manuell corrections $array = array( 'Berlin' => 'BE', ); if (!empty($res['country_province_code']) && array_key_exists($res['country_province_code'], $array)) { $res['country_province_code'] = $array[$res['country_province_code']]; } // inject maxAccuracy for transparency $res['maxAccuracy'] = $this->_getMaxAccuracy($res); return $res; } /** * Fetches url with curl if available * fallbacks: cake and php * note: expects url with json encoded content * * @return mixed **/ protected function _fetch($url) { $this->HttpSocket = new HttpSocketLib($this->use); $this->debugSet('_fetch', $url); if ($res = $this->HttpSocket->fetch($url, 'CakePHP Geocode Lib')) { return $res; } $this->setError($this->HttpSocket->error()); return false; } /** * return debugging info * * @return array $debug */ public function debug() { $this->debug['result'] = $this->result; return $this->debug; } /** * set debugging info * * @param string $key * @param mixed $data * @return void */ public function debugSet($key, $data = null) { $this->debug[$key] = $data; } /** * Calculates Distance between two points - each: array('lat'=>x,'lng'=>y) * DB: '6371.04 * ACOS( COS( PI()/2 - RADIANS(90 - Retailer.lat)) * ' . 'COS( PI()/2 - RADIANS(90 - '. $data['Location']['lat'] .')) * ' . 'COS( RADIANS(Retailer.lng) - RADIANS('. $data['Location']['lng'] .')) + ' . 'SIN( PI()/2 - RADIANS(90 - Retailer.lat)) * ' . 'SIN( PI()/2 - RADIANS(90 - '. $data['Location']['lat'] . '))) ' . 'AS distance' * * @param array pointX * @param array pointY * @param float $unit (M=miles, K=kilometers, N=nautical miles, I=inches, F=feet) * @return int Distance in km */ public function distance(array $pointX, array $pointY, $unit = null) { if (empty($unit) || !array_key_exists(($unit = strtoupper($unit)), $this->units)) { $unit = array_keys($this->units); $unit = $unit[0]; } $res = $this->calculateDistance($pointX, $pointY); if (isset($this->units[$unit])) { $res *= $this->units[$unit]; } return ceil($res); } /** * GeocodeLib::calculateDistance() * * @param array $pointX * @param array $pointY * @return float */ public static function calculateDistance(array $pointX, array $pointY) { /* $res = 6371.04 * ACOS( COS( PI()/2 - rad2deg(90 - $pointX['lat'])) * COS( PI()/2 - rad2deg(90 - $pointY['lat'])) * COS( rad2deg($pointX['lng']) - rad2deg($pointY['lng'])) + SIN( PI()/2 - rad2deg(90 - $pointX['lat'])) * SIN( PI()/2 - rad2deg(90 - $pointY['lat']))); $res = 6371.04 * acos(sin($pointY['lat'])*sin($pointX['lat'])+cos($pointY['lat'])*cos($pointX['lat'])*cos($pointY['lng'] - $pointX['lng'])); */ // seems to be the only working one (although slightly incorrect...) $res = 69.09 * rad2deg(acos(sin(deg2rad($pointX['lat'])) * sin(deg2rad($pointY['lat'])) + cos(deg2rad($pointX['lat'])) * cos(deg2rad($pointY['lat'])) * cos(deg2rad($pointX['lng'] - $pointY['lng'])))); return $res; } /** * Convert between units * * @param float $value * @param char $fromUnit (using class constants) * @param char $toUnit (using class constants) * @return float convertedValue * @throws CakeException */ public function convert($value, $fromUnit, $toUnit) { if (!isset($this->units[($fromUnit = strtoupper($fromUnit))]) || !isset($this->units[($toUnit = strtoupper($toUnit))])) { throw new CakeException(__('Invalid Unit')); } if ($fromUnit === 'M') { $value *= $this->units[$toUnit]; } elseif ($toUnit === 'M') { $value /= $this->units[$fromUnit]; } else { $value /= $this->units[$fromUnit]; $value *= $this->units[$toUnit]; } return $value; } /** * Fuzziness filter for coordinates (lat or lng). * Useful if you store other users' locations and want to grant some * privacy protection. This way the coordinates will be slightly modified. * * @param float coord Coordinates * @param integer level The Level of blurness (0 = nothing to 5 = extrem) * - 1: * - 2: * - 3: * - 4: * - 5: * @return float Coordinates * @throws CakeException */ public static function blur($coord, $level = 0) { if (!$level) { return $coord; } //TODO: switch ($level) { case 1: break; case 2: break; case 3: break; case 4: break; case 5: break; default: throw new CakeException(__('Invalid level \'%s\'', $level)); } $scrambleVal = 0.000001 * mt_rand(1000, 2000) * (mt_rand(0, 1) === 0 ? 1 : -1); return ($coord + $scrambleVal); //$scrambleVal *= (mt_rand(0,1) === 0 ? 1 : 2); //$scrambleVal *= (float)(2^$level); // TODO: + - by chance!!! return $coord + $scrambleVal; } const TYPE_ROOFTOP = 'ROOFTOP'; const TYPE_RANGE_INTERPOLATED = 'RANGE_INTERPOLATED'; const TYPE_GEOMETRIC_CENTER = 'GEOMETRIC_CENTER'; const TYPE_APPROXIMATE = 'APPROXIMATE'; const CODE_SUCCESS = 'OK'; //200; const CODE_TOO_MANY_QUERIES = 'OVER_QUERY_LIMIT'; //620; const CODE_BAD_REQUEST = 'REQUEST_DENIED'; //400; const CODE_MISSING_QUERY = 'INVALID_REQUEST';//601; const CODE_UNKNOWN_ADDRESS = 'ZERO_RESULTS'; //602; /* const CODE_SERVER_ERROR = 500; const CODE_UNAVAILABLE_ADDRESS = 603; const CODE_UNKNOWN_DIRECTIONS = 604; const CODE_BAD_KEY = 610; */ } /* TODO: http://code.google.com/intl/de-DE/apis/maps/documentation/geocoding/ - whats the difference to "http://maps.google.com/maps/api/geocode/output?parameters" */