dawarich/app/services/location_search/geocoding_service.rb

231 lines
7.3 KiB
Ruby
Raw Normal View History

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