Files

677 lines
21 KiB
PHP
Raw Permalink Normal View History

2026-03-15 17:08:23 +07:00
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Maps;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MapsService
{
protected array $config;
protected string $provider;
public function __construct()
{
$this->config = config('restaurant-delivery.maps');
$this->provider = $this->config['provider'];
}
/*
|--------------------------------------------------------------------------
| Route Calculation
|--------------------------------------------------------------------------
*/
/**
* Get route between two points
*/
public function getRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
$cacheKey = "route_{$originLat}_{$originLng}_{$destLat}_{$destLng}_".md5(json_encode($waypoints ?? []));
return Cache::remember(
$cacheKey,
config('restaurant-delivery.cache.ttl.route_calculation'),
function () use ($originLat, $originLng, $destLat, $destLng, $waypoints) {
return match ($this->provider) {
'google' => $this->getGoogleRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
'mapbox' => $this->getMapboxRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
default => $this->getGoogleRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
};
}
);
}
/**
* Get route using Google Maps Directions API
*/
protected function getGoogleRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
try {
$apiKey = $this->config['google']['api_key'];
if (! $apiKey) {
Log::warning('Google Maps API key not configured');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$params = [
'origin' => "{$originLat},{$originLng}",
'destination' => "{$destLat},{$destLng}",
'key' => $apiKey,
'mode' => 'driving',
'departure_time' => 'now',
'traffic_model' => $this->config['route']['traffic_model'],
'alternatives' => $this->config['route']['alternatives'] ? 'true' : 'false',
];
if ($waypoints) {
$params['waypoints'] = implode('|', array_map(
fn ($wp) => "{$wp['lat']},{$wp['lng']}",
$waypoints
));
}
$response = Http::get('https://maps.googleapis.com/maps/api/directions/json', $params);
if (! $response->successful()) {
Log::error('Google Maps API request failed', [
'status' => $response->status(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['routes'])) {
Log::error('Google Maps API returned error', [
'status' => $data['status'],
'error_message' => $data['error_message'] ?? null,
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$route = $data['routes'][0];
$leg = $route['legs'][0];
return [
'polyline' => $route['overview_polyline']['points'],
'points' => $this->decodePolyline($route['overview_polyline']['points']),
'distance' => $leg['distance']['value'] / 1000, // Convert to km
'distance_text' => $leg['distance']['text'],
'duration' => (int) ceil($leg['duration']['value'] / 60), // Convert to minutes
'duration_text' => $leg['duration']['text'],
'duration_in_traffic' => isset($leg['duration_in_traffic'])
? (int) ceil($leg['duration_in_traffic']['value'] / 60)
: null,
'start_address' => $leg['start_address'],
'end_address' => $leg['end_address'],
'steps' => array_map(fn ($step) => [
'instruction' => strip_tags($step['html_instructions']),
'distance' => $step['distance']['value'],
'duration' => $step['duration']['value'],
'start' => $step['start_location'],
'end' => $step['end_location'],
'maneuver' => $step['maneuver'] ?? null,
], $leg['steps']),
'alternatives' => $this->config['route']['alternatives']
? $this->parseAlternativeRoutes($data['routes'])
: [],
];
} catch (\Exception $e) {
Log::error('Google Maps route calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
}
/**
* Get route using Mapbox Directions API
*/
protected function getMapboxRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
try {
$accessToken = $this->config['mapbox']['access_token'];
if (! $accessToken) {
Log::warning('Mapbox access token not configured');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$coordinates = "{$originLng},{$originLat}";
if ($waypoints) {
foreach ($waypoints as $wp) {
$coordinates .= ";{$wp['lng']},{$wp['lat']}";
}
}
$coordinates .= ";{$destLng},{$destLat}";
$params = [
'access_token' => $accessToken,
'geometries' => 'polyline',
'overview' => 'full',
'steps' => 'true',
'alternatives' => $this->config['route']['alternatives'] ? 'true' : 'false',
];
$response = Http::get(
"https://api.mapbox.com/directions/v5/mapbox/driving/{$coordinates}",
$params
);
if (! $response->successful()) {
Log::error('Mapbox API request failed', [
'status' => $response->status(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$data = $response->json();
if (empty($data['routes'])) {
Log::error('Mapbox API returned no routes');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$route = $data['routes'][0];
return [
'polyline' => $route['geometry'],
'points' => $this->decodePolyline($route['geometry']),
'distance' => $route['distance'] / 1000, // Convert to km
'distance_text' => $this->formatDistance($route['distance'] / 1000),
'duration' => (int) ceil($route['duration'] / 60), // Convert to minutes
'duration_text' => $this->formatDuration($route['duration']),
'steps' => array_map(fn ($step) => [
'instruction' => $step['maneuver']['instruction'] ?? '',
'distance' => $step['distance'],
'duration' => $step['duration'],
'maneuver' => $step['maneuver']['type'] ?? null,
], $route['legs'][0]['steps'] ?? []),
'alternatives' => [],
];
} catch (\Exception $e) {
Log::error('Mapbox route calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
}
/**
* Fallback route calculation using Haversine (straight line)
*/
protected function getFallbackRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng
): array {
$distance = $this->calculateHaversineDistance($originLat, $originLng, $destLat, $destLng);
// Estimate duration assuming average speed of 25 km/h
$duration = (int) ceil(($distance / 25) * 60);
// Create a simple polyline (straight line)
$polyline = $this->encodePolyline([
['lat' => $originLat, 'lng' => $originLng],
['lat' => $destLat, 'lng' => $destLng],
]);
return [
'polyline' => $polyline,
'points' => [
['lat' => $originLat, 'lng' => $originLng],
['lat' => $destLat, 'lng' => $destLng],
],
'distance' => $distance,
'distance_text' => $this->formatDistance($distance),
'duration' => $duration,
'duration_text' => $this->formatDuration($duration * 60),
'is_fallback' => true,
'steps' => [],
'alternatives' => [],
];
}
/**
* Parse alternative routes from Google response
*/
protected function parseAlternativeRoutes(array $routes): array
{
$alternatives = [];
for ($i = 1; $i < count($routes); $i++) {
$route = $routes[$i];
$leg = $route['legs'][0];
$alternatives[] = [
'polyline' => $route['overview_polyline']['points'],
'distance' => $leg['distance']['value'] / 1000,
'duration' => (int) ceil($leg['duration']['value'] / 60),
'summary' => $route['summary'] ?? null,
];
}
return $alternatives;
}
/*
|--------------------------------------------------------------------------
| Distance Matrix
|--------------------------------------------------------------------------
*/
/**
* Get distance matrix for multiple origins and destinations
*/
public function getDistanceMatrix(array $origins, array $destinations): ?array
{
if ($this->provider !== 'google' || ! $this->config['google']['distance_matrix_api']) {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
try {
$apiKey = $this->config['google']['api_key'];
$originStr = implode('|', array_map(
fn ($o) => "{$o['lat']},{$o['lng']}",
$origins
));
$destStr = implode('|', array_map(
fn ($d) => "{$d['lat']},{$d['lng']}",
$destinations
));
$response = Http::get('https://maps.googleapis.com/maps/api/distancematrix/json', [
'origins' => $originStr,
'destinations' => $destStr,
'key' => $apiKey,
'mode' => 'driving',
'departure_time' => 'now',
]);
if (! $response->successful()) {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
$data = $response->json();
if ($data['status'] !== 'OK') {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
$matrix = [];
foreach ($data['rows'] as $i => $row) {
$matrix[$i] = [];
foreach ($row['elements'] as $j => $element) {
if ($element['status'] === 'OK') {
$matrix[$i][$j] = [
'distance' => $element['distance']['value'] / 1000,
'duration' => (int) ceil($element['duration']['value'] / 60),
];
} else {
$matrix[$i][$j] = null;
}
}
}
return $matrix;
} catch (\Exception $e) {
Log::error('Distance matrix calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
}
/**
* Fallback distance matrix using Haversine
*/
protected function getFallbackDistanceMatrix(array $origins, array $destinations): array
{
$matrix = [];
foreach ($origins as $i => $origin) {
$matrix[$i] = [];
foreach ($destinations as $j => $dest) {
$distance = $this->calculateHaversineDistance(
$origin['lat'],
$origin['lng'],
$dest['lat'],
$dest['lng']
);
$matrix[$i][$j] = [
'distance' => $distance,
'duration' => (int) ceil(($distance / 25) * 60),
];
}
}
return $matrix;
}
/*
|--------------------------------------------------------------------------
| Geocoding
|--------------------------------------------------------------------------
*/
/**
* Geocode an address to coordinates
*/
public function geocode(string $address): ?array
{
$cacheKey = 'geocode_'.md5($address);
return Cache::remember($cacheKey, 86400, function () use ($address) {
if ($this->provider === 'google' && $this->config['google']['geocoding_api']) {
return $this->googleGeocode($address);
}
return null;
});
}
/**
* Reverse geocode coordinates to address
*/
public function reverseGeocode(float $latitude, float $longitude): ?array
{
$cacheKey = "reverse_geocode_{$latitude}_{$longitude}";
return Cache::remember($cacheKey, 86400, function () use ($latitude, $longitude) {
if ($this->provider === 'google' && $this->config['google']['geocoding_api']) {
return $this->googleReverseGeocode($latitude, $longitude);
}
return null;
});
}
/**
* Google geocoding
*/
protected function googleGeocode(string $address): ?array
{
try {
$response = Http::get('https://maps.googleapis.com/maps/api/geocode/json', [
'address' => $address,
'key' => $this->config['google']['api_key'],
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['results'])) {
return null;
}
$result = $data['results'][0];
return [
'lat' => $result['geometry']['location']['lat'],
'lng' => $result['geometry']['location']['lng'],
'formatted_address' => $result['formatted_address'],
'place_id' => $result['place_id'],
'components' => $this->parseAddressComponents($result['address_components']),
];
} catch (\Exception $e) {
Log::error('Geocoding failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Google reverse geocoding
*/
protected function googleReverseGeocode(float $latitude, float $longitude): ?array
{
try {
$response = Http::get('https://maps.googleapis.com/maps/api/geocode/json', [
'latlng' => "{$latitude},{$longitude}",
'key' => $this->config['google']['api_key'],
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['results'])) {
return null;
}
$result = $data['results'][0];
return [
'formatted_address' => $result['formatted_address'],
'place_id' => $result['place_id'],
'components' => $this->parseAddressComponents($result['address_components']),
];
} catch (\Exception $e) {
Log::error('Reverse geocoding failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Parse address components from Google response
*/
protected function parseAddressComponents(array $components): array
{
$parsed = [];
foreach ($components as $component) {
foreach ($component['types'] as $type) {
$parsed[$type] = $component['long_name'];
}
}
return $parsed;
}
/*
|--------------------------------------------------------------------------
| Utility Methods
|--------------------------------------------------------------------------
*/
/**
* Calculate Haversine distance
*/
public function calculateHaversineDistance(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$earthRadius = config('restaurant-delivery.distance.earth_radius_km');
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return round($earthRadius * $c, 2);
}
/**
* Decode polyline string to array of points
*/
public function decodePolyline(string $encoded): array
{
$points = [];
$index = 0;
$lat = 0;
$lng = 0;
while ($index < strlen($encoded)) {
$shift = 0;
$result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1F) << $shift;
$shift += 5;
} while ($b >= 0x20);
$dlat = (($result & 1) ? ~($result >> 1) : ($result >> 1));
$lat += $dlat;
$shift = 0;
$result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1F) << $shift;
$shift += 5;
} while ($b >= 0x20);
$dlng = (($result & 1) ? ~($result >> 1) : ($result >> 1));
$lng += $dlng;
$points[] = [
'lat' => $lat / 1e5,
'lng' => $lng / 1e5,
];
}
return $points;
}
/**
* Encode array of points to polyline string
*/
public function encodePolyline(array $points): string
{
$encoded = '';
$prevLat = 0;
$prevLng = 0;
foreach ($points as $point) {
$lat = (int) round($point['lat'] * 1e5);
$lng = (int) round($point['lng'] * 1e5);
$encoded .= $this->encodeNumber($lat - $prevLat);
$encoded .= $this->encodeNumber($lng - $prevLng);
$prevLat = $lat;
$prevLng = $lng;
}
return $encoded;
}
/**
* Encode a number for polyline
*/
protected function encodeNumber(int $num): string
{
$encoded = '';
$num = $num < 0 ? ~($num << 1) : ($num << 1);
while ($num >= 0x20) {
$encoded .= chr((0x20 | ($num & 0x1F)) + 63);
$num >>= 5;
}
$encoded .= chr($num + 63);
return $encoded;
}
/**
* Format distance for display
*/
protected function formatDistance(float $km): string
{
if ($km < 1) {
return round($km * 1000).' m';
}
return round($km, 1).' km';
}
/**
* Format duration for display
*/
protected function formatDuration(int $seconds): string
{
$minutes = (int) ceil($seconds / 60);
if ($minutes < 60) {
return $minutes.' min';
}
$hours = (int) floor($minutes / 60);
$mins = $minutes % 60;
return $hours.' hr '.$mins.' min';
}
/**
* Calculate bearing between two points
*/
public function calculateBearing(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$lat1Rad = deg2rad($lat1);
$lat2Rad = deg2rad($lat2);
$dLng = deg2rad($lng2 - $lng1);
$x = sin($dLng) * cos($lat2Rad);
$y = cos($lat1Rad) * sin($lat2Rad) - sin($lat1Rad) * cos($lat2Rad) * cos($dLng);
$bearing = atan2($x, $y);
$bearing = rad2deg($bearing);
return fmod($bearing + 360, 360);
}
}