diff --git a/readme.md b/readme.md index 4203623..242e9f3 100644 --- a/readme.md +++ b/readme.md @@ -17,5 +17,6 @@ namespace `Clevis\Geolocation` #### geocoding: - Address - Geocoder - - Google\GeocodingApiClient ( *IGeocodingService*, *IReverseGeocodingService* ) + - Google\GeocodingClient ( *IGeocodingService*, *IReverseGeocodingService* ) + - Nominatim\GeocodingClient ( *IGeocodingService*, *IReverseGeocodingService* ) - Google\ElevationApiClient ( *IElevationService* ) diff --git a/src/Geolocation/Google/GeocodingClient.php b/src/Geolocation/Google/GeocodingClient.php index 3713fe3..597633b 100644 --- a/src/Geolocation/Google/GeocodingClient.php +++ b/src/Geolocation/Google/GeocodingClient.php @@ -23,6 +23,7 @@ class GeocodingClient extends Object implements IGeocodingService const GOOGLE_URL = 'http://maps.googleapis.com/maps/api/geocode/json?'; + private $viewportBias = null; /** * Get GPS position for given address @@ -139,6 +140,35 @@ public function getResponse($query, $options) return $this->query($options); } + /** + * Set the geocoder to bias on a "rectangle" defined by two Position "corners"; + * following queries will prefer the viewport set this way. + * Note that there are always two areas likely to be defined this way (see the links); + * choosing the correct one is up to the implementation. + * + * @see http://stackoverflow.com/questions/23084764/draw-rectangle-on-map-given-two-opposite-coordinates-determine-which-ones-ar#comment35283253_23084764 + * @see http://i.piskvor.org/test/which.png + * @see http://i.piskvor.org/test/which2.png + * + * @param Position $corner1 + * @param Position $corner2 + * @return boolean true if region biasing is available, false otherwise + */ + public function setBias(Position $corner1, Position $corner2) { + $this->viewportBias = array($corner1,$corner2); + } + + + /** + * Reset the bias set by setBias; following queries will not have a preferred viewport + * + * @return void + */ + public function unsetBias() { + $this->viewportBias = null; + } + + /** * Executes query on The Google Geocoding API * @@ -153,6 +183,20 @@ private function query(array $options) { $options['sensor'] = $options['sensor'] ? 'true' : 'false'; } + + if (is_array($this->viewportBias) && (count($this->viewportBias) == 2)) { + /** @var Position $corner1 */ + $corner1 = $this->viewportBias[0]; + /** @var Position $corner2 */ + $corner2 = $this->viewportBias[1]; + /** @see https://developers.google.com/maps/documentation/geocoding/#Viewports */ + $options['bounds'] = ( + $corner1->latitude . ',' . $corner1->longitude + . '|' + . $corner2->latitude . ',' . $corner2->longitude + ); + } + $url = self::GOOGLE_URL . http_build_query($options); $curl = curl_init(); diff --git a/src/Geolocation/Nominatim/GeocodingClient.php b/src/Geolocation/Nominatim/GeocodingClient.php new file mode 100644 index 0000000..c96c414 --- /dev/null +++ b/src/Geolocation/Nominatim/GeocodingClient.php @@ -0,0 +1,283 @@ +base_url = $url; + } + + /** + * Get GPS position for given address + * + * @param Address|string + * @param array + * @param bool + * @return Position|NULL + */ + public function getPosition($address, $options = array(), $fullResult = FALSE) + { + // address + if ($address instanceof Address) + { + $address = (string) $address; + } + elseif (!is_string($address)) + { + throw new InvalidArgumentException('Address should be instance of Address or a string.'); + } + + // bounds + if (!empty($options['bounds']) && $options['bounds'] instanceof Rectangle) + { + /** @var Rectangle $rec */ + $rec = $options['bounds']; + $b = $rec->getLatLonBounds(); + $options['viewboxlbrt'] = "$b[0],$b[1]|$b[2],$b[3]"; + $options['bounded'] = 1; + unset($options['bounds']); + } + + if ($this->munge_address) { + $address = $this->mungeAddress($address); + } + + + $result = $this->getResponse($address, $options); + + return $fullResult ? $result : $result->getPosition(); + } + + /** + * Get address for given GPS position + * + * @param Position + * @param array + * @param bool + * @return Address|NULL + */ + public function getAddress(Position $position, $options = array(), $fullResult = FALSE) + { + $result = $this->getResponse($position, $options); + + return $fullResult ? $result : $result->getAddress(); + } + + /** + * Get both position and address for given query + * + * @param string|Address|Position + * @param array + * @return array (Position|NULL, Address|NULL) + */ + public function getPositionAndAddress($query, $options = array()) + { + if ($query instanceof Position) + { + /** @var GeocodingResponse $response */ + $response = $this->getAddress($query, $options, TRUE); + if ($response) + { + return array($response->getPosition(), $response->getAddress()); + } + } + else + { + /** @var GeocodingResponse $response */ + $response = $this->getPosition($query, $options, TRUE); + if ($response) + { + return array($response->getPosition(), $response->getAddress()); + } + } + + return array(NULL, NULL); + } + + /** + * Get a full geocoding query result + * + * @param string|Address|Position + * @return GeocodingResponse + */ + public function getResponse($query, $options) + { + if ($query instanceof Position) + { + $options['lat'] = $query->latitude; + $options['lon'] = $query->longitude; // NB: not "lng"! + } + else + { + $options['lat'] = null; + $options['lon'] = null; + $options['q'] = ((string) $query); // NB: not "address"! + } + + return $this->query($options); + } + + /** + * Set the geocoder to bias on a "rectangle" defined by two Position "corners"; + * following queries will prefer the viewport set this way. + * Note that there are always two areas likely to be defined this way (see the links); + * choosing the correct one is up to the implementation. + * + * @see http://stackoverflow.com/questions/23084764/draw-rectangle-on-map-given-two-opposite-coordinates-determine-which-ones-ar#comment35283253_23084764 + * @see http://i.piskvor.org/test/which.png + * @see http://i.piskvor.org/test/which2.png + * + * @param Position $corner1 + * @param Position $corner2 + * @return boolean true if region biasing is available, false otherwise + */ + public function setBias(Position $corner1, Position $corner2) { + $this->viewport_bias = array($corner1,$corner2); + } + + + /** + * Reset the bias set by setBias; following queries will not have a preferred viewport + * + * @return void + */ + public function unsetBias() { + $this->viewport_bias = null; + } + + /** + * Executes query on OSM Nominatim API + * + * @param string + * @param string + * @param array [bounds, language, region, sensor] + * @return \StdClass + */ + protected function query(array $options) + { + if ($options['lat'] && $options['lon']) { + $method = 'reverse'; + } else { + $method = 'search'; + } + + if (is_array($this->viewport_bias) && (count($this->viewport_bias) == 2)) { + /** @var Position $corner1 */ + $corner1 = $this->viewport_bias[0]; + /** @var Position $corner2 */ + $corner2 = $this->viewport_bias[1]; + /** @see http://wiki.openstreetmap.org/wiki/Nominatim */ + $options['viewbox'] = ( + $corner1->latitude . ',' . $corner1->longitude + . ',' + . $corner2->latitude . ',' . $corner2->longitude + ); + $options['bounded'] = '1'; + } + + $options['format'] = 'json'; + $options['addressdetails'] = 1; + $options['email'] = $this->email; + + $url = $this->base_url . $method . '?' . http_build_query($options); + + $curl = curl_init(); + + curl_setopt($curl, CURLOPT_HTTPHEADER, Array('Content-Type: application/json; charset=utf-8')); + + if ($this->user_name) { + curl_setopt($curl, CURLOPT_USERPWD, $this->user_name . ':' . $this->user_pwd); + } + if ($this->ua) { + curl_setopt($curl, CURLOPT_USERAGENT,$this->ua); + } + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_URL, $url); + $response = curl_exec($curl); + $response_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if (!$response) + { + throw new ConnectionException("Unable to connect to geocoding API."); + } + $payload = @json_decode($response); // @ - intentionally to escalate error to exception + + if ($payload && !is_array($payload)) { + $payload = array($payload); + } + + if (!$payload) + { + if ((is_array($payload) && (count($payload) == 0))) + { + throw new InvalidStatusException("Geocoding query failed (no results)."); + + } else + { + throw new InvalidResponseException("Unable to parse response from geocoding API."); + } + } + if ($response_code != 200) + { + throw new InvalidStatusException("Geocoding query failed (status: '" . $response_code . "')."); + } + + return new GeocodingResponse($this, $payload, $options); + } + + protected function mungeAddress($address) { + $address = preg_replace('/\d{3}\W*\d{2}/','',$address); // no ZIP codes - assuming there is no 5-digit house number + $matched = 0; + do { // remove all numbers behind the first comma, if any + $address = preg_replace('/,([^\d]*?)([\d]+)/',',\\1',$address, -1, $matched); + } while ($matched > 0); + $address = preg_replace('/(Praha|Brno|Olomouc|Plzeň|Plzen|Ostrava|Pardubice) +\d+/i',',\\1',$address, -1, $matched); // remove "Praha 3", as this administrative district division is useless to us; worse, not all address points have it! + $address = preg_replace('/,? +,?/',' ',$address); + + return $address; + } +} diff --git a/src/Geolocation/Nominatim/GeocodingResponse.php b/src/Geolocation/Nominatim/GeocodingResponse.php new file mode 100644 index 0000000..b1e2c49 --- /dev/null +++ b/src/Geolocation/Nominatim/GeocodingResponse.php @@ -0,0 +1,244 @@ + stdClass] other found locations */ + private $alternatives; + + /** @var array address components */ + private $components = array(); + + + /** + * @param GoogleGeocodingClient + * @param StdClass[] + * @param array + */ + public function __construct(GeocodingClient $client, array $result, $options) + { + $this->client = $client; + $this->result = array_shift($result); + $this->options = $options; + $this->alternatives = $result; + + foreach ($this->result->address as $type => $component) + { + $this->components[$type] = $component; + } + } + + /** + * @return Address + */ + public function getAddress() + { + $c = $this->components; + + $address = new Address; + $address->language = isset($this->options['language']) ? $this->options['language'] : NULL; + + $address->country = $this->getCountry(); + $address->countryCode = $this->getCountryCode(); + + // state + if (in_array($address->countryCode, self::$countriesWithStates)) + { + if (isset($c['state'])) + { + $address->state = $c['state']; + $address->stateCode = NULL; + } + } + + // region/county + if (isset($c['county'])) + { + $address->region = $c['county']; + $address->regionCode = NULL; + } + + $address->district = isset($c['state_district']) + ? $c['state_district'] : NULL; + + $address->town = isset($c['city']) ? $c['city'] : NULL; + + $address->quarter = isset($c['suburb']) ? $c['suburb'] : NULL; + + $address->neighborhood = isset($c['neighborhood']) ? $c['neighborhood'] : NULL; + + $address->postalCode = isset($c['postcode']) ? $c['postcode'] : NULL; + + $address->street = isset($c['road']) ? $c['road'] : NULL; + + // house and street number (číslo popisné a orientační) + $n = array(); + if (isset($c['house_number'])) + { + $n[] = isset($c['house_number']); + } + if (isset($c['street_number'])) + { + $n[] = isset($c['street_number']); + } + $n = implode('/', $n); + $address->number = $n ? $n : NULL; + + $address->formatedAddress = $this->getFormatedAddress(); + + $address->partialMatch = $this->isPartialMatch(); + + return $address; + } + + /** + * Returns formatted label + * + * @return string + */ + public function getFormatedAddress() + { + return $this->result->display_name; // returns ugly addresses in CZ - @TODO: FIXME maybe? + } + + /** + * Indicates that the response doesn't match exactly the original querys + * + * @return bool + */ + public function isPartialMatch() + { + return false; // no such thing in OSM (yet?) + } + + /** + * Returns GPS position + * + * @return Position + */ + public function getPosition() + { + return new Position( + $this->result->lat, + $this->result->lon + ); + } + + /** + * Returns rectangle area + * + * @return Rectangle + */ + public function getArea() + { + $bounds = $this->result->boundingbox; + + return new Rectangle( + new Position($bounds[0], $bounds[2]), + new Position($bounds[1], $bounds[3]) + ); + } + + /** + * Returns name of country + * + * @return string|NULL + */ + public function getCountry() + { + return isset($this->components['country']) ? $this->components['country'] : NULL; + } + + /** + * Returns short international symbol of country + * + * @return string + */ + public function getCountryCode() + { + return isset($this->components['country_code']) ? $this->components['country_code'] : NULL; + } + + /** + * Returns TRUE if current Address has any alternatives + * (returned by OSM Nominatim API) + * + * @return bool + */ + public function hasAlternatives() + { + return (bool) count($this->getAlternatives()); + } + + /** + * Returns array of alternative Addresses + * (returned by OSM Nominatim API) + * + * @return array [# => Address] + */ + public function getAlternatives() + { + $client = $this->client; + $label = $this->getFormatedAddress(); + $class = get_class($this); + return array_filter(array_map(function ($alternative) use ($client, $label, $class) { + if ($alternative->display_name === $label + || in_array('train_station', $alternative->types) + || in_array('postal_town', $alternative->types) + ) { + return NULL; + } + return new $class($client, array($alternative)); + }, $this->alternatives), function ($alternative) { return (bool) $alternative;}); + } + + /** + * Returns original response + * + * @internal + * @return stdClass + */ + public function getRaw() + { + return $this->result; + } + +} diff --git a/src/Geolocation/interfaces/IGeocodingService.php b/src/Geolocation/interfaces/IGeocodingService.php index 9ec798f..82974da 100644 --- a/src/Geolocation/interfaces/IGeocodingService.php +++ b/src/Geolocation/interfaces/IGeocodingService.php @@ -35,4 +35,25 @@ function getAddress(Position $position, $options = array()); */ function getPositionAndAddress($query); + /** + * Set the geocoder to bias on a "rectangle" defined by two Position "corners"; + * following queries will prefer the viewport set this way. + * Note that there are always two areas likely to be defined this way (see the links); + * choosing the correct one is up to the implementation. + * @see http://stackoverflow.com/questions/23084764/draw-rectangle-on-map-given-two-opposite-coordinates-determine-which-ones-ar#comment35283253_23084764 + * @see http://i.piskvor.org/test/which.png + * @see http://i.piskvor.org/test/which2.png + * + * @param Position $corner1 + * @param Position $corner2 + * @return boolean true if region biasing is available, false otherwise + */ + function setBias(Position $corner1, Position $corner2); + + /** + * Reset the bias set by setBias; following queries will not have a preferred viewport + * @return void + */ + function unsetBias(); + } diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..e90123e --- /dev/null +++ b/src/index.php @@ -0,0 +1,38 @@ +getPositionAndAddress($address); +$resultGoogle = $geocoderGoogle->getPositionAndAddress($address); + +echo $resultOSM[1]->formatedAddress , "\n"; +echo $resultGoogle[1]->formatedAddress , "\n"; + +$position = \Clevis\Geolocation\Position::fromString("50°1'57.8\"N 15°46'56.21\"E"); +var_dump($position); + +$resultOSM = $geocoderOSM->getPositionAndAddress($position); +var_dump($resultOSM); +$resultGoogle = $geocoderGoogle->getPositionAndAddress($position); + +var_dump($resultGoogle); + +