163 lines
		
	
	
		
			4.9 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			163 lines
		
	
	
		
			4.9 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
namespace App\Services\Location;
 | 
						|
 | 
						|
use Illuminate\Support\Arr;
 | 
						|
use Illuminate\Support\Facades\Http;
 | 
						|
use Illuminate\Support\Facades\Log;
 | 
						|
 | 
						|
class Geocoder
 | 
						|
{
 | 
						|
    public function __construct(
 | 
						|
        private array $cfg = []
 | 
						|
    ) {
 | 
						|
        $this->cfg = config('services.geocoding');
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Forward geocode a free-form string.
 | 
						|
     * Returns a normalized array for your `locations` table or null.
 | 
						|
     */
 | 
						|
    public function forward(string $query): ?array
 | 
						|
    {
 | 
						|
        $provider = $this->cfg['provider'] ?? 'arcgis';
 | 
						|
 | 
						|
        // Treat obvious non-address labels as non-geocodable (e.g., "Home", "Office")
 | 
						|
        if (mb_strlen(trim($query)) < 4) {
 | 
						|
            return null;
 | 
						|
        }
 | 
						|
 | 
						|
        return match ($provider) {
 | 
						|
            'arcgis' => $this->forwardArcgis($query),
 | 
						|
            default  => null,
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Reverse geocode lon/lat → address. (Optional)
 | 
						|
     */
 | 
						|
    public function reverse(float $lat, float $lon): ?array
 | 
						|
    {
 | 
						|
        $provider = $this->cfg['provider'] ?? 'arcgis';
 | 
						|
        return match ($provider) {
 | 
						|
            'arcgis' => $this->reverseArcgis($lat, $lon),
 | 
						|
            default  => null,
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    /* ---------------- ArcGIS World Geocoding ---------------- */
 | 
						|
 | 
						|
    private function http()
 | 
						|
    {
 | 
						|
        return Http::retry(3, 500)
 | 
						|
            ->timeout((int) ($this->cfg['timeout'] ?? 20))
 | 
						|
            ->withHeaders([
 | 
						|
                'User-Agent' => $this->cfg['user_agent'] ?? 'Kithkin/Geocoder',
 | 
						|
            ]);
 | 
						|
    }
 | 
						|
 | 
						|
    private function arcgisBase(): string
 | 
						|
    {
 | 
						|
        return rtrim($this->cfg['arcgis']['endpoint'], '/');
 | 
						|
    }
 | 
						|
 | 
						|
    private function forwardArcgis(string $query): ?array
 | 
						|
    {
 | 
						|
        $params = [
 | 
						|
            'singleLine'  => $query,
 | 
						|
            'outFields'   => $this->cfg['arcgis']['out_fields'] ?? '*',
 | 
						|
            'maxLocations'=> $this->cfg['arcgis']['max_results'] ?? 1,
 | 
						|
            'f'           => 'pjson',
 | 
						|
            'token'       => $this->cfg['arcgis']['api_key'],
 | 
						|
        ];
 | 
						|
 | 
						|
        // If your plan permits/requests it:
 | 
						|
        if (!empty($this->cfg['arcgis']['store'])) {
 | 
						|
            $params['forStorage'] = 'true';
 | 
						|
        }
 | 
						|
 | 
						|
        $res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
 | 
						|
 | 
						|
        if (!$res->ok()) {
 | 
						|
            Log::warning('ArcGIS forward geocode failed', ['status' => $res->status(), 'q' => $query]);
 | 
						|
            return null;
 | 
						|
        }
 | 
						|
 | 
						|
        $json = $res->json();
 | 
						|
        $cand = Arr::first($json['candidates'] ?? []);
 | 
						|
        if (!$cand) {
 | 
						|
            return null;
 | 
						|
        }
 | 
						|
 | 
						|
        return $this->normalizeArcgisCandidate($cand, $query);
 | 
						|
    }
 | 
						|
 | 
						|
    private function reverseArcgis(float $lat, float $lon): ?array
 | 
						|
    {
 | 
						|
        $params = [
 | 
						|
            // ArcGIS expects x=lon, y=lat
 | 
						|
            'location' => "{$lon},{$lat}",
 | 
						|
            'f'        => 'pjson',
 | 
						|
            'token'    => $this->cfg['arcgis']['api_key'],
 | 
						|
        ];
 | 
						|
 | 
						|
        if (!empty($this->cfg['arcgis']['store'])) {
 | 
						|
            $params['forStorage'] = 'true';
 | 
						|
        }
 | 
						|
 | 
						|
        $res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
 | 
						|
 | 
						|
        if (!$res->ok()) {
 | 
						|
            Log::warning('ArcGIS reverse geocode failed', ['status' => $res->status()]);
 | 
						|
            return null;
 | 
						|
        }
 | 
						|
 | 
						|
        $j = $res->json();
 | 
						|
        $addr = $j['address'] ?? null;
 | 
						|
        if (!$addr) {
 | 
						|
            return null;
 | 
						|
        }
 | 
						|
 | 
						|
        return [
 | 
						|
            'display_name' => $addr['LongLabel'] ?? $addr['Match_addr'] ?? null,
 | 
						|
            'raw_address'  => $addr['Match_addr'] ?? null,
 | 
						|
            'street'       => $addr['Address']     ?? null,
 | 
						|
            'city'         => $addr['City']        ?? null,
 | 
						|
            'state'        => $addr['Region']      ?? null,
 | 
						|
            'postal'       => $addr['Postal']      ?? null,
 | 
						|
            'country'      => $addr['CountryCode'] ?? null,
 | 
						|
            // reverseGeocode returns location as x/y too:
 | 
						|
            'lat'          => Arr::get($j, 'location.y'),
 | 
						|
            'lon'          => Arr::get($j, 'location.x'),
 | 
						|
        ];
 | 
						|
    }
 | 
						|
 | 
						|
    private function normalizeArcgisCandidate(array $c, string $query): array
 | 
						|
    {
 | 
						|
        $loc   = $c['location']   ?? [];
 | 
						|
        $attr  = $c['attributes'] ?? [];
 | 
						|
 | 
						|
        // Prefer LongLabel → address → Place_addr
 | 
						|
        $display = $attr['LongLabel']
 | 
						|
            ?? $c['address']
 | 
						|
            ?? $attr['Place_addr']
 | 
						|
            ?? $query;
 | 
						|
 | 
						|
        // ArcGIS often returns both 'Address' and 'Place_addr'; use either.
 | 
						|
        $street = $attr['Address']    ?? $attr['Place_addr'] ?? null;
 | 
						|
 | 
						|
        return [
 | 
						|
            'display_name' => $display,
 | 
						|
            'raw_address'  => $query,
 | 
						|
            'street'       => $street,
 | 
						|
            'city'         => $attr['City']        ?? null,
 | 
						|
            'state'        => $attr['Region']      ?? null,
 | 
						|
            'postal'       => $attr['Postal']      ?? null,
 | 
						|
            'country'      => $attr['CountryCode'] ?? null,
 | 
						|
            // location.x = lon, location.y = lat
 | 
						|
            'lat'          => $loc['y'] ?? null,
 | 
						|
            'lon'          => $loc['x'] ?? null,
 | 
						|
        ];
 | 
						|
    }
 | 
						|
}
 |