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, ), ), ); } }