api/src/Geography/Service/GeoSearcher.php
<?php
/**
* Copyright (c) 2019, MOBICOOP. All rights reserved.
* This project is dual licensed under AGPL and proprietary licence.
***************************
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
***************************
* Licence MOBICOOP described in the file
* LICENSE
*/
namespace App\Geography\Service;
use App\Community\Entity\CommunityUser;
use App\Event\Entity\Event;
use App\Event\Repository\EventRepository;
use App\Geography\Entity\Address;
use App\Geography\ProviderFactory\PeliasAddress;
use App\Geography\Repository\AddressRepository;
use App\Image\Repository\IconRepository;
use App\RelayPoint\Entity\RelayPoint;
use App\RelayPoint\Repository\RelayPointRepository;
use App\User\Entity\User;
use App\User\Repository\UserRepository;
use Geocoder\Model\Bounds;
use Geocoder\Plugin\PluginProvider;
use Geocoder\Query\GeocodeQuery;
use Geocoder\Query\ReverseQuery;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* The geo searcher service.
*
* @author Sylvain Briat <sylvain.briat@covivo.eu>
*/
class GeoSearcher
{
public const ICON_ADDRESS_ANY = 1;
public const ICON_ADDRESS_PERSONAL = 2;
public const ICON_COMMUNITY = 3;
public const ICON_EVENT = 4;
public const ICON_VENUE = 23;
private $geocoder;
private $geoTools;
private $userRepository;
private $addressRepository;
private $relayPointRepository;
private $iconRepository;
private $security;
private $iconPath;
private $dataPath;
private $eventRepository;
private $defaultSigResultNumber;
private $defaultSigReturnedResultNumber;
private $defaultNamedResultNumber;
private $defaultRelayPointResultNumber;
private $defaultEventResultNumber;
private $geoDataFixes;
private $distanceOrder;
private $sigPrioritizeCoordinates;
private $sigPrioritizeRegion;
private $sigShowVenues;
private $_geocodeInput;
private $_geocodeResults;
private $_geocodeTemporaryResults = [];
private $_geocodeRelaypointsResults = [];
private $_geocodeEventpointsResults = [];
private $_user;
private $_userPrioritize;
/**
* Constructor.
*/
public function __construct(
PluginProvider $geocoder,
GeoTools $geoTools,
UserRepository $userRepository,
AddressRepository $addressRepository,
RelayPointRepository $relayPointRepository,
EventRepository $eventRepository,
IconRepository $iconRepository,
Security $security,
TranslatorInterface $translator,
string $iconPath,
string $dataPath,
string $defaultSigResultNumber,
int $defaultSigReturnedResultNumber,
string $defaultNamedResultNumber,
string $defaultRelayPointResultNumber,
string $defaultEventResultNumber,
array $geoDataFixes,
bool $distanceOrder,
array $sigPrioritizeCoordinates,
string $sigPrioritizeRegion,
bool $sigShowVenues
) {
$this->geocoder = $geocoder;
$this->geoTools = $geoTools;
$this->userRepository = $userRepository;
$this->addressRepository = $addressRepository;
$this->relayPointRepository = $relayPointRepository;
$this->iconRepository = $iconRepository;
$this->security = $security;
$this->translator = $translator;
$this->iconPath = $iconPath;
$this->dataPath = $dataPath;
$this->eventRepository = $eventRepository;
$this->defaultSigResultNumber = $defaultSigResultNumber;
$this->defaultSigReturnedResultNumber = $defaultSigReturnedResultNumber;
$this->defaultNamedResultNumber = $defaultNamedResultNumber;
$this->defaultRelayPointResultNumber = $defaultRelayPointResultNumber;
$this->defaultEventResultNumber = $defaultEventResultNumber;
$this->geoDataFixes = $geoDataFixes;
$this->distanceOrder = $distanceOrder;
$this->sigPrioritizeCoordinates = $sigPrioritizeCoordinates;
$this->sigPrioritizeRegion = $sigPrioritizeRegion;
$this->sigShowVenues = $sigShowVenues;
}
/**
* Returns an array of result addresses (named addresses, relaypoints, sig addresses...).
*
* @param string $input The string representing the user input
*
* @return array The results
*/
public function geoCode(string $input)
{
// the result array will contain different addresses :
// - named addresses (if the user is logged)
// - relaypoints (with or without private relaypoints depending on if th user is logged)
// - sig addresses
// - other objects ? to be defined
$this->_geocodeResults = [];
$this->_geocodeTemporaryResults = [];
$this->_geocodeRelaypointsResults = [];
$this->_geocodeEventpointsResults = [];
// First we handle the quote
$this->_geocodeInput = str_replace("'", "''", $input);
// we search if the user is a real user (not an app)
$userPrioritize = null;
$user = $this->security->getUser();
$this->_user = $user;
if ($user instanceof User) {
// we search its home address
foreach ($user->getAddresses() as $address) {
if ($address->isHome()) {
$userPrioritize = [
'latitude' => $address->getLatitude(),
'longitude' => $address->getLongitude(),
];
break;
}
}
}
$this->_userPrioritize = $userPrioritize;
// Set the results by categories
$this->setGeocodeNamedAddresses();
$this->setGeocodeSigAddresses();
$this->setGeocodeRelaypoints();
$this->setGeocodeEventpoints();
// Build the array to be returned
$this->buidGeocodeResults();
return $this->_geocodeResults;
}
/**
* Returns an array of reversed geocoded addresses.
*
* @param float $lat The latitude
* @param float $lon The longitude
*
* @return array The array of addresses found
*/
public function reverseGeoCode(float $lat, float $lon)
{
$addresses = [];
if ($geoResults = $this->geocoder->reverseQuery(ReverseQuery::fromCoordinates($lat, $lon))) {
foreach ($geoResults as $geoResult) {
$address = new Address();
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_ADDRESS_ANY)->getFileName());
if ($geoResult->getCoordinates() && $geoResult->getCoordinates()->getLatitude()) {
$address->setLatitude((string) $geoResult->getCoordinates()->getLatitude());
}
if ($geoResult->getCoordinates() && $geoResult->getCoordinates()->getLongitude()) {
$address->setLongitude((string) $geoResult->getCoordinates()->getLongitude());
}
$address->setHouseNumber($geoResult->getStreetNumber());
$address->setStreet($geoResult->getStreetName());
$address->setStreetAddress($geoResult->getStreetName() ? trim(($geoResult->getStreetNumber() ? $geoResult->getStreetNumber() : '').' '.$geoResult->getStreetName()) : null);
$address->setSubLocality($geoResult->getSubLocality());
$address->setAddressLocality($geoResult->getLocality());
foreach ($geoResult->getAdminLevels() as $level) {
switch ($level->getLevel()) {
case 1:
$address->setLocalAdmin($level->getName());
break;
case 2:
$address->setCounty($level->getName());
break;
case 3:
$address->setMacroCounty($level->getName());
break;
case 4:
$address->setRegion($level->getName());
break;
case 5:
$address->setMacroRegion($level->getName());
break;
}
}
$address->setPostalCode($geoResult->getPostalCode());
if ($geoResult->getCountry() && $geoResult->getCountry()->getName()) {
$address->setAddressCountry($geoResult->getCountry()->getName());
}
if ($geoResult->getCountry() && $geoResult->getCountry()->getCode()) {
$address->setCountryCode($geoResult->getCountry()->getCode());
}
// add layer if handled by the provider
if (method_exists($geoResult, 'getLayer')) {
$address->setLayer($this->getLayer($geoResult->getLayer()));
}
// add venue if handled by the provider
if (method_exists($geoResult, 'getVenue')) {
$address->setVenue($geoResult->getVenue());
}
if ((method_exists($geoResult, 'getEstablishment')) && (null != $geoResult->getEstablishment())) {
$address->setVenue($geoResult->getEstablishment());
}
if ((method_exists($geoResult, 'getPointOfInterest')) && (null != $geoResult->getPointOfInterest())) {
$address->setVenue($geoResult->getPointOfInterest());
}
if ($address->getVenue()) {
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_VENUE)->getFileName());
}
// add id and fix result if handled by the provider
if (method_exists($geoResult, 'getId')) {
$address = $this->fixAddress($geoResult->getId(), $address);
}
$address->setDisplayLabel($this->geoTools->getDisplayLabel($address));
$addresses[] = $address;
}
return $addresses;
}
return false;
}
private function buidGeocodeResults()
{
$this->mergeNamedResultsIntoGeocodeResults($this->_geocodeTemporaryResults);
$this->mergeNamedResultsIntoGeocodeResults($this->_geocodeRelaypointsResults);
$this->mergeNamedResultsIntoGeocodeResults($this->_geocodeEventpointsResults);
}
private function mergeNamedResultsIntoGeocodeResults(array $data)
{
$this->_geocodeResults = array_merge($this->_geocodeResults, $data);
}
private function setGeocodeNamedAddresses()
{
if ($this->_user instanceof User) {
$namedAddresses = $this->addressRepository->findByName($this->translator->trans($this->_geocodeInput), $this->_user->getId());
if (count($namedAddresses) > 0) {
$i = 0;
foreach ($namedAddresses as $address) {
$address->setDisplayLabel($this->geoTools->getDisplayLabel($address, $this->_user));
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_ADDRESS_PERSONAL)->getFileName());
array_push($this->_geocodeTemporaryResults, $address);
++$i;
if ($i >= $this->defaultNamedResultNumber) {
break;
}
}
}
}
}
private function setGeocodeSigAddresses()
{
// The query always include SIG_GEOCODER_PRIORITIZE_COORDINATES (see services.yaml Georouter.query_data_plugin)
// But some SIG use the Bound param for a viewbox/zone so we need to detect if it's only a point or a viewbox
// Check the options (priozitize, viewbox, region...)
$optionUserPrioritize = $optionBounds = $optionRegion = false;
// If there is viewbox in .env SIG_GEOCODER_PRIORITIZE_COORDINATES
if (
isset($this->sigPrioritizeCoordinates['minLatitude'], $this->sigPrioritizeCoordinates['maxLatitude'], $this->sigPrioritizeCoordinates['minLongitude'], $this->sigPrioritizeCoordinates['maxLongitude'])
) {
$optionBounds = true;
}
// Centroid on user's home address
if (!is_null($this->_userPrioritize)) {
$optionUserPrioritize = true;
}
// Specific region using ccTLD standard (https://en.wikipedia.org/wiki/CcTLD)
$optionRegion = '' !== $this->sigPrioritizeRegion;
// Considering the options, we build the request
if ($optionUserPrioritize && !$optionBounds && !$optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withData('userPrioritize', $this->_userPrioritize)
;
} elseif (!$optionUserPrioritize && $optionBounds && !$optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withBounds(new Bounds($this->sigPrioritizeCoordinates['minLatitude'], $this->sigPrioritizeCoordinates['minLongitude'], $this->sigPrioritizeCoordinates['maxLatitude'], $this->sigPrioritizeCoordinates['maxLongitude']))
;
} elseif (!$optionUserPrioritize && !$optionBounds && $optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withData('region', $this->sigPrioritizeRegion)
;
} elseif ($optionUserPrioritize && $optionBounds && !$optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withData('userPrioritize', $this->_userPrioritize)
->withBounds(new Bounds($this->sigPrioritizeCoordinates['minLatitude'], $this->sigPrioritizeCoordinates['minLongitude'], $this->sigPrioritizeCoordinates['maxLatitude'], $this->sigPrioritizeCoordinates['maxLongitude']))
;
} elseif (!$optionUserPrioritize && $optionBounds && $optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withData('region', $this->sigPrioritizeRegion)
->withBounds(new Bounds($this->sigPrioritizeCoordinates['minLatitude'], $this->sigPrioritizeCoordinates['minLongitude'], $this->sigPrioritizeCoordinates['maxLatitude'], $this->sigPrioritizeCoordinates['maxLongitude']))
;
} elseif ($optionUserPrioritize && $optionBounds && $optionRegion) {
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
->withData('userPrioritize', $this->_userPrioritize)
->withData('region', $this->sigPrioritizeRegion)
->withBounds(new Bounds($this->sigPrioritizeCoordinates['minLatitude'], $this->sigPrioritizeCoordinates['minLongitude'], $this->sigPrioritizeCoordinates['maxLatitude'], $this->sigPrioritizeCoordinates['maxLongitude']))
;
} else {
// Not specific option
$query = GeocodeQuery::create($this->_geocodeInput)
->withLimit($this->defaultSigResultNumber)
;
}
$geoResults = $this->geocoder->geocodeQuery($query)->all();
foreach ($geoResults as $geoResult) {
/**
* @var PeliasAddress $geoResult
*/
// ?? todo : exclude all results that doesn't include any input word at all
$address = new Address();
// set address icon
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_ADDRESS_ANY)->getFileName());
if ($geoResult->getCoordinates() && $geoResult->getCoordinates()->getLatitude()) {
$address->setLatitude((string) $geoResult->getCoordinates()->getLatitude());
}
if ($geoResult->getCoordinates() && $geoResult->getCoordinates()->getLongitude()) {
$address->setLongitude((string) $geoResult->getCoordinates()->getLongitude());
}
$address->setHouseNumber($geoResult->getStreetNumber());
$address->setStreet($geoResult->getStreetName());
$address->setStreetAddress($geoResult->getStreetName() ? trim(($geoResult->getStreetNumber() ? $geoResult->getStreetNumber() : '').' '.$geoResult->getStreetName()) : null);
$address->setSubLocality($geoResult->getSubLocality());
$address->setAddressLocality($geoResult->getLocality());
foreach ($geoResult->getAdminLevels() as $level) {
switch ($level->getLevel()) {
case 1:
$address->setLocalAdmin($level->getName());
break;
case 2:
$address->setCounty($level->getName());
break;
case 3:
$address->setMacroCounty($level->getName());
break;
case 4:
$address->setRegion($level->getName());
break;
case 5:
$address->setMacroRegion($level->getName());
break;
}
}
$address->setPostalCode($geoResult->getPostalCode());
if ($geoResult->getCountry() && $geoResult->getCountry()->getName()) {
$address->setAddressCountry($geoResult->getCountry()->getName());
}
if ($geoResult->getCountry() && $geoResult->getCountry()->getCode()) {
$address->setCountryCode($geoResult->getCountry()->getCode());
}
// add layer if handled by the provider
if (method_exists($geoResult, 'getLayer')) {
$address->setLayer($this->getLayer($geoResult->getLayer()));
}
// add venue if handled by the provider
if (method_exists($geoResult, 'getVenue')) {
$address->setVenue($geoResult->getVenue());
}
if ((method_exists($geoResult, 'getEstablishment')) && (null != $geoResult->getEstablishment())) {
$address->setVenue($geoResult->getEstablishment());
}
if ((method_exists($geoResult, 'getPointOfInterest')) && (null != $geoResult->getPointOfInterest())) {
$address->setVenue($geoResult->getPointOfInterest());
}
$address->setProvidedBy($geoResult->getProvidedBy());
if ($address->getVenue()) {
if (!$this->sigShowVenues) {
continue;
}
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_VENUE)->getFileName());
}
if (method_exists($geoResult, 'getDistance')) {
if (!is_null($geoResult->getDistance())) {
$address->setDistance($geoResult->getDistance());
}
}
// add id and fix result if handled by the provider
if (method_exists($geoResult, 'getId')) {
$address = $this->fixAddress($geoResult->getId(), $address);
}
$address->setDisplayLabel($this->geoTools->getDisplayLabel($address, $this->_user));
// We set the similarity (algorithm method)
$address->setSimilarityWithSearch(levenshtein($this->_geocodeInput, $address->getAddressLocality()));
// Before adding a new address we check if there is a similar already in the array
// If so, we take the tinier layer index
$addAddress = true;
foreach ($this->_geocodeTemporaryResults as $address_key => $previous_address) {
if (0 == count(array_diff($address->getDisplayLabel(), $previous_address->getDisplayLabel()))) {
if ($address->getLayer() < $previous_address->getLayer()) {
$this->_geocodeTemporaryResults[$address_key] = $address;
}
$addAddress = false;
break;
}
}
if ($addAddress) {
array_push($this->_geocodeTemporaryResults, $address);
}
usort($this->_geocodeTemporaryResults, function ($a, $b) {
return $a->getSimilarityWithSearch() > $b->getSimilarityWithSearch();
});
}
if ($this->distanceOrder) {
usort($this->_geocodeTemporaryResults, function ($a, $b) {
return $a->getDistance() > $b->getDistance();
});
}
$this->_geocodeTemporaryResults = array_slice($this->_geocodeTemporaryResults, 0, $this->defaultSigReturnedResultNumber);
return $this->_geocodeTemporaryResults;
}
private function setGeocodeRelaypoints()
{
$relayPoints = $this->relayPointRepository->findByNameAndStatus($this->_geocodeInput, RelayPoint::STATUS_ACTIVE);
// exclude the private relay points
$i = 0;
foreach ($relayPoints as $relayPoint) {
$exclude = false;
if ($relayPoint->getCommunity() && $relayPoint->isPrivate()) {
$exclude = true;
if ($this->_user) {
// todo : maybe find a quicker way than a foreach :)
foreach ($relayPoint->getCommunity()->getCommunityUsers() as $communityUser) {
if ($communityUser->getUser()->getId() == $this->_user->getId() && $communityUser->getStatus() == (CommunityUser::STATUS_ACCEPTED_AS_MEMBER or CommunityUser::STATUS_ACCEPTED_AS_MODERATOR)) {
$exclude = false;
break;
}
}
}
}
if (!$exclude) {
$address = $relayPoint->getAddress();
$address->setRelayPoint($relayPoint);
// set address icon
$relayPointType = $relayPoint->getRelayPointType();
if (!is_null($relayPointType) && is_null($relayPointType->getIcon())) {
$relayPointType->setIcon($this->iconRepository->find(1));
}
if (!is_null($relayPointType) && !is_null($relayPointType->getIcon())) {
if ($relayPointType->getIcon()->getPrivateIconLinked()) {
$address->setIcon($this->dataPath.$this->iconPath.$relayPointType->getIcon()->getPrivateIconLinked()->getFileName());
} else {
$address->setIcon($this->dataPath.$this->iconPath.$relayPointType->getIcon()->getFileName());
}
}
$address->setDisplayLabel($this->geoTools->getDisplayLabel($address, $this->_user));
array_push($this->_geocodeRelaypointsResults, $address);
++$i;
if ($i >= $this->defaultRelayPointResultNumber) {
break;
}
}
}
}
private function setGeocodeEventpoints()
{
$events = $this->eventRepository->findByNameAndStatus($this->_geocodeInput, Event::STATUS_ACTIVE);
// exclude the private relay points
$i = 0;
foreach ($events as $event) {
$address = $event->getAddress();
$address->setEvent($event);
$address->setDisplayLabel($this->geoTools->getDisplayLabel($address, $this->_user));
$address->setIcon($this->dataPath.$this->iconPath.$this->iconRepository->find(self::ICON_EVENT)->getFileName());
array_push($this->_geocodeEventpointsResults, $address);
++$i;
if ($i >= $this->defaultEventResultNumber) {
break;
}
}
}
/**
* Fix potential wrong addresses.
*
* @param string $id The id of the source data
* @param Address $address The address to fix
*
* @return Address The address fixed
*/
private function fixAddress(string $id, Address $address)
{
// we search in the fixes if there's one corresponding to the id
if (array_key_exists($id, $this->geoDataFixes)) {
foreach ($this->geoDataFixes[$id] as $property => $value) {
if (method_exists($address, 'set'.ucfirst($property))) {
$method = 'set'.ucfirst($property);
$address->{$method}($value);
}
}
}
return $address;
}
/**
* Get layer id by layer string.
*
* @param string $layer The string layer
*
* @return null|int The int layer or null
*/
private function getLayer(string $layer): ?int
{
switch ($layer) {
case 'address':
return Address::LAYER_ADDRESS;
case 'locality':
return Address::LAYER_LOCALITY;
case 'localadmin':
return Address::LAYER_LOCALADMIN;
default:
return null;
}
}
}