kithkin/app/Services/Location/Geocoder.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,
];
}
}