2025-08-30 17:18:16 -04:00
# frozen_string_literal: true
module LocationSearch
class GeocodingService
2025-09-01 16:04:55 -04:00
MAX_RESULTS = 10
2025-08-30 17:18:16 -04:00
CACHE_TTL = 1 . hour
def initialize
@cache_key_prefix = 'location_search:geocoding'
end
def search ( query )
return [ ] if query . blank?
cache_key = " #{ @cache_key_prefix } : #{ Digest :: SHA256 . hexdigest ( query . downcase ) } "
Rails . cache . fetch ( cache_key , expires_in : CACHE_TTL ) do
perform_geocoding_search ( query )
end
rescue StandardError = > e
Rails . logger . error " Geocoding search failed for query ' #{ query } ': #{ e . message } "
[ ]
end
def provider_name
Geocoder . config . lookup . to_s . capitalize
end
private
def perform_geocoding_search ( query )
2025-08-31 06:08:33 -04:00
Rails . logger . info " LocationSearch::GeocodingService: Searching for ' #{ query } ' using #{ provider_name } "
# Try original query first
2025-08-30 17:18:16 -04:00
results = Geocoder . search ( query , limit : MAX_RESULTS )
2025-08-31 06:08:33 -04:00
Rails . logger . info " LocationSearch::GeocodingService: Raw geocoder returned #{ results . length } results "
# If we got results but they seem too generic (common chain names),
# also try with location context
if results . length > 1 && looks_like_chain_store? ( query )
Rails . logger . info " LocationSearch::GeocodingService: Query looks like chain store, trying with Berlin context "
berlin_results = Geocoder . search ( " #{ query } Berlin " , limit : MAX_RESULTS )
Rails . logger . info " LocationSearch::GeocodingService: Berlin-specific search returned #{ berlin_results . length } results "
# Prioritize Berlin results
results = ( berlin_results + results ) . uniq
end
2025-08-30 17:18:16 -04:00
return [ ] if results . blank?
2025-08-31 06:08:33 -04:00
normalized = normalize_geocoding_results ( results )
Rails . logger . info " LocationSearch::GeocodingService: After normalization: #{ normalized . length } results "
normalized
2025-08-30 17:18:16 -04:00
end
def normalize_geocoding_results ( results )
normalized_results = [ ]
2025-08-31 06:08:33 -04:00
results . each_with_index do | result , idx |
unless valid_result? ( result )
Rails . logger . warn " LocationSearch::GeocodingService: Result #{ idx } is invalid: lat= #{ result . latitude } , lon= #{ result . longitude } "
next
end
2025-08-30 17:18:16 -04:00
normalized_result = {
lat : result . latitude . to_f ,
lon : result . longitude . to_f ,
name : extract_name ( result ) ,
address : extract_address ( result ) ,
type : extract_type ( result ) ,
provider_data : extract_provider_data ( result )
}
2025-08-31 06:08:33 -04:00
Rails . logger . info " LocationSearch::GeocodingService: Result #{ idx } : ' #{ normalized_result [ :name ] } ' at [ #{ normalized_result [ :lat ] } , #{ normalized_result [ :lon ] } ] "
2025-08-30 17:18:16 -04:00
normalized_results << normalized_result
end
# Remove duplicates based on coordinates (within 100m)
2025-08-31 06:08:33 -04:00
deduplicated = deduplicate_results ( normalized_results )
Rails . logger . info " LocationSearch::GeocodingService: After deduplication: #{ deduplicated . length } results "
deduplicated
2025-08-30 17:18:16 -04:00
end
def valid_result? ( result )
result . present? &&
result . latitude . present? &&
result . longitude . present? &&
result . latitude . to_f . abs < = 90 &&
result . longitude . to_f . abs < = 180
end
def extract_name ( result )
case provider_name . downcase
when 'photon'
extract_photon_name ( result )
when 'nominatim'
extract_nominatim_name ( result )
when 'geoapify'
extract_geoapify_name ( result )
else
result . address || result . data & . dig ( 'display_name' ) || 'Unknown location'
end
end
def extract_address ( result )
case provider_name . downcase
when 'photon'
extract_photon_address ( result )
when 'nominatim'
extract_nominatim_address ( result )
when 'geoapify'
extract_geoapify_address ( result )
else
result . address || result . data & . dig ( 'display_name' ) || ''
end
end
def extract_type ( result )
data = result . data || { }
case provider_name . downcase
when 'photon'
data . dig ( 'properties' , 'osm_key' ) || data . dig ( 'properties' , 'type' ) || 'unknown'
when 'nominatim'
data [ 'type' ] || data [ 'class' ] || 'unknown'
when 'geoapify'
data . dig ( 'properties' , 'datasource' , 'sourcename' ) || data . dig ( 'properties' , 'place_type' ) || 'unknown'
else
'unknown'
end
end
def extract_provider_data ( result )
{
osm_id : result . data & . dig ( 'properties' , 'osm_id' ) ,
osm_type : result . data & . dig ( 'properties' , 'osm_type' ) ,
place_rank : result . data & . dig ( 'place_rank' ) ,
importance : result . data & . dig ( 'importance' )
}
end
# Provider-specific extractors
def extract_photon_name ( result )
properties = result . data & . dig ( 'properties' ) || { }
properties [ 'name' ] || properties [ 'street' ] || properties [ 'city' ] || 'Unknown location'
end
def extract_photon_address ( result )
properties = result . data & . dig ( 'properties' ) || { }
parts = [ ]
parts << properties [ 'street' ] if properties [ 'street' ] . present?
parts << properties [ 'housenumber' ] if properties [ 'housenumber' ] . present?
parts << properties [ 'city' ] if properties [ 'city' ] . present?
parts << properties [ 'state' ] if properties [ 'state' ] . present?
parts << properties [ 'country' ] if properties [ 'country' ] . present?
parts . join ( ', ' )
end
def extract_nominatim_name ( result )
data = result . data || { }
data [ 'display_name' ] & . split ( ',' ) & . first || 'Unknown location'
end
def extract_nominatim_address ( result )
result . data & . dig ( 'display_name' ) || ''
end
def extract_geoapify_name ( result )
properties = result . data & . dig ( 'properties' ) || { }
properties [ 'name' ] || properties [ 'street' ] || properties [ 'city' ] || 'Unknown location'
end
def extract_geoapify_address ( result )
properties = result . data & . dig ( 'properties' ) || { }
properties [ 'formatted' ] || ''
end
def deduplicate_results ( results )
deduplicated = [ ]
results . each do | result |
# Check if there's already a result within 100m
duplicate = deduplicated . find do | existing |
distance = calculate_distance (
result [ :lat ] , result [ :lon ] ,
existing [ :lat ] , existing [ :lon ]
)
distance < 100 # meters
end
deduplicated << result unless duplicate
end
deduplicated
end
def calculate_distance ( lat1 , lon1 , lat2 , lon2 )
# Haversine formula for distance calculation in meters
rad_per_deg = Math :: PI / 180
rkm = 6371000 # Earth radius in meters
dlat_rad = ( lat2 - lat1 ) * rad_per_deg
dlon_rad = ( lon2 - lon1 ) * rad_per_deg
lat1_rad = lat1 * rad_per_deg
lat2_rad = lat2 * rad_per_deg
a = Math . sin ( dlat_rad / 2 ) ** 2 + Math . cos ( lat1_rad ) * Math . cos ( lat2_rad ) * Math . sin ( dlon_rad / 2 ) ** 2
c = 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) )
rkm * c
end
2025-08-31 06:08:33 -04:00
def looks_like_chain_store? ( query )
chain_patterns = [
/ \ b(netto|kaufland|rewe|edeka|aldi|lidl|penny|real) \ b /i ,
/ \ b(mcdonalds?|burger king|kfc|subway) \ b /i ,
/ \ b(shell|aral|esso|bp|total) \ b /i ,
/ \ b(dm|rossmann|müller) \ b /i ,
/ \ b(h&m|c&a|zara|primark) \ b /i
]
chain_patterns . any? { | pattern | query . match? ( pattern ) }
end
2025-08-30 17:18:16 -04:00
end
end