296 lines
8.8 KiB
PHP
296 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Location;
|
|
|
|
use \App\Models\User;
|
|
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 with optional per-user bias
|
|
*/
|
|
public function forward(string $query, ?User $user = null): ?array
|
|
{
|
|
if (mb_strlen(trim($query)) < 4) {
|
|
return null;
|
|
}
|
|
|
|
return match ($this->cfg['provider'] ?? 'arcgis') {
|
|
'arcgis' => $this->forwardArcgis($query, $this->biasForUser($user)),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* reverse geocode lon/lat → address. (Optional)
|
|
*/
|
|
public function reverse(float $lat, float $lon): ?array
|
|
{
|
|
return match ($this->cfg['provider'] ?? 'arcgis') {
|
|
'arcgis' => $this->reverseArcgis($lat, $lon),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/*
|
|
*
|
|
* ArcGIS World Geocoding
|
|
*/
|
|
|
|
/**
|
|
* request
|
|
*/
|
|
private function http()
|
|
{
|
|
return Http::retry(3, 500)
|
|
->timeout((int) ($this->cfg['timeout'] ?? 20))
|
|
->withHeaders([
|
|
'User-Agent' => $this->cfg['user_agent'] ?? 'Kithkin/Geocoder',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* get base url
|
|
*/
|
|
private function arcgisBase(): string
|
|
{
|
|
return rtrim($this->cfg["arcgis"]["endpoint"], "/");
|
|
}
|
|
|
|
/**
|
|
* pull a bias from the user (zip -> centroid) and cache it
|
|
*/
|
|
private function biasForUser(?User $user): ?array
|
|
{
|
|
if (!$user) {
|
|
return null;
|
|
}
|
|
|
|
// if you store home_lat/home_lon on users, prefer those
|
|
if (!empty($user->home_lat) && !empty($user->home_lon)) {
|
|
return ['lat' => (float)$user->home_lat, 'lon' => (float)$user->home_lon, 'radius_km' => 120.0];
|
|
}
|
|
|
|
// else try user zip/postal
|
|
$zip = $user->postal_code ?? $user->zip ?? null;
|
|
if (!$zip) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = "geo:bias:zip:{$zip}";
|
|
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip) {
|
|
$a = $this->cfg['arcgis'];
|
|
|
|
$params = [
|
|
'singleLine' => $zip,
|
|
'category' => 'Postal',
|
|
'maxLocations' => 1,
|
|
'f' => 'pjson',
|
|
'token' => $a['api_key'],
|
|
'countryCode' => $a['country_code'] ?? null,
|
|
];
|
|
|
|
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
|
if (!$res->ok()) {
|
|
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
|
|
return null;
|
|
}
|
|
|
|
$cand = Arr::first($res->json('candidates', []));
|
|
if (!$cand || empty($cand['location'])) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'lat' => (float)($cand['location']['y'] ?? 0),
|
|
'lon' => (float)($cand['location']['x'] ?? 0),
|
|
'radius_km' => 120.0, // reasonable city-scale radius
|
|
];
|
|
});
|
|
|
|
return $bias ?: null;
|
|
}
|
|
|
|
/**
|
|
* compute a bounding box string from center + radius (km)
|
|
*/
|
|
private function bboxFromBias(float $lat, float $lon, float $radiusKm): string
|
|
{
|
|
$latDelta = $radiusKm / 111.0;
|
|
$lonDelta = $radiusKm / (111.0 * max(cos(deg2rad($lat)), 0.01));
|
|
$minx = $lon - $lonDelta;
|
|
$miny = $lat - $latDelta;
|
|
$maxx = $lon + $lonDelta;
|
|
$maxy = $lat + $latDelta;
|
|
return "{$minx},{$miny},{$maxx},{$maxy}";
|
|
}
|
|
|
|
/**
|
|
* handle location request with optional bias array
|
|
*/
|
|
private function forwardArcgis(string $query, ?array $bias): ?array
|
|
{
|
|
$a = $this->cfg['arcgis'];
|
|
|
|
$params = [
|
|
'singleLine' => $query,
|
|
'outFields' => $a['out_fields'] ?? '*',
|
|
'maxLocations' => (int)($a['max_results'] ?? 5),
|
|
'f' => 'pjson',
|
|
'token' => $a['api_key'],
|
|
'category' => $a['categories'] ?? 'POI,Address',
|
|
'countryCode' => $a['country_code'] ?? null,
|
|
];
|
|
|
|
if ($bias && $bias['lat'] && $bias['lon']) {
|
|
$params['location'] = $bias['lon'].','.$bias['lat'];
|
|
if (!empty($bias['radius_km'])) {
|
|
$params['searchExtent'] = $this->bboxFromBias($bias['lat'], $bias['lon'], (float)$bias['radius_km']);
|
|
}
|
|
}
|
|
|
|
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
|
if (!$res->ok()) {
|
|
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
|
return null;
|
|
}
|
|
|
|
$cands = $res->json('candidates', []);
|
|
if (!$cands) {
|
|
return null;
|
|
}
|
|
|
|
// simplest “best”: highest arcgis score
|
|
usort($cands, fn($a,$b) => ($b['score'] <=> $a['score']));
|
|
$best = $cands[0];
|
|
|
|
return $this->normalizeArcgisCandidate($best, $query);
|
|
}
|
|
|
|
/**
|
|
* lookup based on lat/lon
|
|
*/
|
|
private function reverseArcgis(float $lat, float $lon): ?array
|
|
{
|
|
$a = $this->cfg['arcgis'];
|
|
$params = [
|
|
'location' => "{$lon},{$lat}",
|
|
'f' => 'pjson',
|
|
'token' => $a['api_key'],
|
|
];
|
|
|
|
$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,
|
|
'lat' => Arr::get($j, 'location.y'),
|
|
'lon' => Arr::get($j, 'location.x'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* format the returned data
|
|
*/
|
|
private function normalizeArcgisCandidate(array $c, string $query): array
|
|
{
|
|
$loc = $c['location'] ?? [];
|
|
$attr = $c['attributes'] ?? [];
|
|
|
|
$display = $attr['LongLabel'] ?? $c['address'] ?? $attr['Place_addr'] ?? $query;
|
|
$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,
|
|
'lat' => $loc['y'] ?? null,
|
|
'lon' => $loc['x'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* get top-n suggestions for a free-form query
|
|
*/
|
|
public function suggestions(string $query, int $limit = 5, ?User $user = null): array
|
|
{
|
|
$provider = $this->cfg["provider"] ?? "arcgis";
|
|
|
|
if (mb_strlen(trim($query)) < 3) {
|
|
return [];
|
|
}
|
|
|
|
return match ($provider) {
|
|
"arcgis" => $this->arcgisSuggestions($query, $limit),
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* get the suggestions from arcgis
|
|
*/
|
|
private function arcgisSuggestions(string $query, int $limit): array
|
|
{
|
|
$params = [
|
|
"singleLine" => $query,
|
|
"outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*",
|
|
"maxLocations" => $limit,
|
|
// you can bias results with 'countryCode' or 'location' here if desired
|
|
"f" => "pjson",
|
|
"token" => $this->cfg["arcgis"]["api_key"],
|
|
];
|
|
|
|
if (!empty($this->cfg["arcgis"]["store"])) {
|
|
$params["forStorage"] = "true";
|
|
}
|
|
|
|
$res = $this->http()->get(
|
|
$this->arcgisBase() . "/findAddressCandidates",
|
|
$params,
|
|
);
|
|
if (!$res->ok()) {
|
|
return [];
|
|
}
|
|
|
|
$json = $res->json();
|
|
$cands = array_slice($json["candidates"] ?? [], 0, $limit);
|
|
|
|
// normalize to the same structure used elsewhere
|
|
return array_values(
|
|
array_filter(
|
|
array_map(
|
|
fn($c) => $this->normalizeArcgisCandidate($c, $query),
|
|
$cands,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|