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