mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 10:51:10 -05:00
247 lines
7.2 KiB
PHP
247 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Exceptions\GeocodingException;
|
|
use App\Exceptions\ValidationException;
|
|
use Illuminate\Http\Client\Factory as HttpClient;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class GeocodeService
|
|
{
|
|
private const BASE_URL = 'https://nominatim.openstreetmap.org';
|
|
private const CACHE_TTL = 86400; // 24 hours
|
|
private const USER_AGENT = 'ThrillWiki Geocoder';
|
|
private const RATE_LIMIT = 1; // 1 request per second
|
|
|
|
private HttpClient $http;
|
|
private ?float $lastRequestTime = null;
|
|
|
|
public function __construct(HttpClient $http)
|
|
{
|
|
$this->http = $http;
|
|
}
|
|
|
|
/**
|
|
* Convert an address to coordinates
|
|
*
|
|
* @param string $address
|
|
* @return array|null
|
|
* @throws GeocodingException|ValidationException
|
|
*/
|
|
public function geocode(string $address): ?array
|
|
{
|
|
$this->validateAddress($address);
|
|
|
|
$cacheKey = "geocode:" . md5($address);
|
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($address) {
|
|
try {
|
|
$this->enforceRateLimit();
|
|
|
|
$response = $this->http->withHeaders([
|
|
'User-Agent' => self::USER_AGENT
|
|
])->get(self::BASE_URL . '/search', [
|
|
'q' => $address,
|
|
'format' => 'json',
|
|
'addressdetails' => 1,
|
|
'limit' => 1
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
throw new GeocodingException('Geocoding API request failed: ' . $response->status());
|
|
}
|
|
|
|
$data = $response->json();
|
|
if (empty($data)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'lat' => (float) $data[0]['lat'],
|
|
'lon' => (float) $data[0]['lon'],
|
|
'display_name' => $data[0]['display_name'],
|
|
'address' => $data[0]['address'] ?? [],
|
|
'type' => $data[0]['type'] ?? null,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('Geocoding error', [
|
|
'address' => $address,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
throw new GeocodingException('Failed to geocode address: ' . $e->getMessage());
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert coordinates to an address
|
|
*
|
|
* @param float $lat
|
|
* @param float $lon
|
|
* @return array|null
|
|
* @throws GeocodingException|ValidationException
|
|
*/
|
|
public function reverseGeocode(float $lat, float $lon): ?array
|
|
{
|
|
$this->validateCoordinates($lat, $lon);
|
|
|
|
$cacheKey = "reverse:{$lat},{$lon}";
|
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($lat, $lon) {
|
|
try {
|
|
$this->enforceRateLimit();
|
|
|
|
$response = $this->http->withHeaders([
|
|
'User-Agent' => self::USER_AGENT
|
|
])->get(self::BASE_URL . '/reverse', [
|
|
'lat' => $lat,
|
|
'lon' => $lon,
|
|
'format' => 'json',
|
|
'addressdetails' => 1
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
throw new GeocodingException('Reverse geocoding API request failed: ' . $response->status());
|
|
}
|
|
|
|
$data = $response->json();
|
|
if (empty($data)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'display_name' => $data['display_name'],
|
|
'address' => $data['address'] ?? [],
|
|
'type' => $data['type'] ?? null,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('Reverse geocoding error', [
|
|
'lat' => $lat,
|
|
'lon' => $lon,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
throw new GeocodingException('Failed to reverse geocode coordinates: ' . $e->getMessage());
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Geocode multiple addresses
|
|
*
|
|
* @param array $addresses
|
|
* @return array
|
|
*/
|
|
public function batchGeocode(array $addresses): array
|
|
{
|
|
$results = [];
|
|
foreach ($addresses as $address) {
|
|
try {
|
|
$results[$address] = $this->geocode($address);
|
|
} catch (\Exception $e) {
|
|
$results[$address] = null;
|
|
Log::error('Batch geocoding error', [
|
|
'address' => $address,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Validate coordinates
|
|
*
|
|
* @param float $lat
|
|
* @param float $lon
|
|
* @return bool
|
|
* @throws ValidationException
|
|
*/
|
|
public function validateCoordinates(float $lat, float $lon): bool
|
|
{
|
|
if ($lat < -90 || $lat > 90) {
|
|
throw new ValidationException('Invalid latitude. Must be between -90 and 90');
|
|
}
|
|
|
|
if ($lon < -180 || $lon > 180) {
|
|
throw new ValidationException('Invalid longitude. Must be between -180 and 180');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear geocoding cache
|
|
*
|
|
* @param string|null $key
|
|
* @return void
|
|
*/
|
|
public function clearCache(?string $key = null): void
|
|
{
|
|
if ($key) {
|
|
Cache::forget($key);
|
|
} else {
|
|
// Clear all geocoding related cache
|
|
$keys = Cache::get('geocoding_cache_keys', []);
|
|
foreach ($keys as $cacheKey) {
|
|
Cache::forget($cacheKey);
|
|
}
|
|
Cache::forget('geocoding_cache_keys');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Warm up cache with common addresses
|
|
*
|
|
* @param array $addresses
|
|
* @return void
|
|
*/
|
|
public function warmCache(array $addresses): void
|
|
{
|
|
foreach ($addresses as $address) {
|
|
try {
|
|
$this->geocode($address);
|
|
} catch (\Exception $e) {
|
|
Log::error('Cache warming error', [
|
|
'address' => $address,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate address string
|
|
*
|
|
* @param string $address
|
|
* @return bool
|
|
* @throws ValidationException
|
|
*/
|
|
private function validateAddress(string $address): bool
|
|
{
|
|
if (strlen($address) < 5) {
|
|
throw new ValidationException('Address must be at least 5 characters long');
|
|
}
|
|
|
|
if (strlen($address) > 200) {
|
|
throw new ValidationException('Address must not exceed 200 characters');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Enforce rate limiting for API requests
|
|
*
|
|
* @return void
|
|
*/
|
|
private function enforceRateLimit(): void
|
|
{
|
|
if ($this->lastRequestTime !== null) {
|
|
$timeSinceLastRequest = microtime(true) - $this->lastRequestTime;
|
|
if ($timeSinceLastRequest < self::RATE_LIMIT) {
|
|
usleep(($this->RATE_LIMIT - $timeSinceLastRequest) * 1000000);
|
|
}
|
|
}
|
|
$this->lastRequestTime = microtime(true);
|
|
}
|
|
} |