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"], "/"); } /** * fetch arcgis api key (log once if missing) */ private function arcgisKey(): ?string { $key = $this->cfg['arcgis']['api_key'] ?? null; if (!$key) { $this->warnMissingKey(); return null; } return $key; } private function warnMissingKey(): void { if ($this->missingKeyWarned) { return; } $this->missingKeyWarned = true; Log::warning('arcgis api key missing; geocoding disabled'); } private function arcgisDebugEnabled(): bool { return (bool) ($this->cfg['arcgis']['debug'] ?? false); } private function logArcgisResponse(string $label, array $params, $res): void { if (! $this->arcgisDebugEnabled()) { return; } $safeParams = $params; if (isset($safeParams['token'])) { $safeParams['token'] = '***'; } Log::info("arcgis {$label} response", [ 'status' => $res->status(), 'params' => $safeParams, 'body' => $res->body(), ]); } /** * 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; } $key = $this->arcgisKey(); if (!$key) { return null; } $cacheKey = "geo:bias:zip:{$zip}"; $bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip, $key) { $a = $this->cfg['arcgis']; $params = [ 'singleLine' => $zip, 'category' => 'Postal', 'maxLocations' => 1, 'f' => 'pjson', 'token' => $key, 'countryCode' => $a['country_code'] ?? null, ]; $res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params); $this->logArcgisResponse('zip-bias', $params, $res); 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']; $key = $this->arcgisKey(); if (!$key) { return null; } $params = [ 'singleLine' => $query, 'outFields' => $a['out_fields'] ?? '*', 'maxLocations' => (int)($a['max_results'] ?? 5), 'f' => 'pjson', 'token' => $key, 'category' => $a['categories'] ?? 'POI,Address', 'countryCode' => $a['country_code'] ?? null, ]; if (!empty($a['store'])) { $params['forStorage'] = 'true'; } 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); $this->logArcgisResponse('forward', $params, $res); if (!$res->ok()) { Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]); return null; } $cands = $res->json('candidates', []); if (!$cands) { if ($this->arcgisDebugEnabled()) { Log::info('arcgis forward geocode empty', ['q' => $query]); } 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']; $key = $this->arcgisKey(); if (!$key) { return null; } $params = [ 'location' => "{$lon},{$lat}", 'f' => 'pjson', 'token' => $key, ]; if (!empty($a['store'])) { $params['forStorage'] = 'true'; } $res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params); $this->logArcgisResponse('reverse', $params, $res); 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 []; } $bias = $this->biasForUser($user); return match ($provider) { "arcgis" => $this->arcgisSuggestions($query, $limit, $bias), default => [], }; } /** * get the suggestions from arcgis */ private function arcgisSuggestions(string $query, int $limit, ?array $bias = null): array { $key = $this->arcgisKey(); if (!$key) { return []; } $params = [ "singleLine" => $query, "outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*", "maxLocations" => $limit, "category" => $this->cfg["arcgis"]["categories"] ?? "POI,Address", "countryCode" => $this->cfg["arcgis"]["country_code"] ?? null, "f" => "pjson", "token" => $key, ]; if ($bias && $bias['lat'] && $bias['lon']) { $params['location'] = $bias['lon'] . ',' . $bias['lat']; if (!empty($bias['radius_km'])) { $params['searchExtent'] = $this->bboxFromBias( (float) $bias['lat'], (float) $bias['lon'], (float) $bias['radius_km'] ); } } if (!empty($this->cfg["arcgis"]["store"])) { $params["forStorage"] = "true"; } $res = $this->http()->get( $this->arcgisBase() . "/findAddressCandidates", $params, ); $this->logArcgisResponse('suggestions', $params, $res); 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, ), ), ); } }