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