mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
272 lines
9.3 KiB
Ruby
272 lines
9.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Distanceable
|
|
extend ActiveSupport::Concern
|
|
|
|
module ClassMethods
|
|
def total_distance(points = nil, unit = :km)
|
|
if points.nil?
|
|
calculate_distance_for_relation(unit)
|
|
else
|
|
calculate_distance_for_array(points, unit)
|
|
end
|
|
end
|
|
|
|
# In-memory distance calculation using Geocoder (no SQL dependency)
|
|
def calculate_distance_for_array_geocoder(points, unit = :km)
|
|
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
|
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
|
end
|
|
|
|
return 0 if points.length < 2
|
|
|
|
total_meters = points.each_cons(2).sum do |p1, p2|
|
|
# Extract coordinates from lonlat (source of truth)
|
|
begin
|
|
# Check if lonlat exists and is valid
|
|
if p1.lonlat.nil? || p2.lonlat.nil?
|
|
Rails.logger.warn "Skipping distance calculation for points with nil lonlat: p1(#{p1.id}), p2(#{p2.id})"
|
|
next 0
|
|
end
|
|
|
|
lat1, lon1 = p1.lat, p1.lon
|
|
lat2, lon2 = p2.lat, p2.lon
|
|
|
|
# Check for nil coordinates extracted from lonlat
|
|
if lat1.nil? || lon1.nil? || lat2.nil? || lon2.nil?
|
|
Rails.logger.warn "Skipping distance calculation for points with nil extracted coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
|
next 0
|
|
end
|
|
|
|
# Check for NaN or infinite coordinates
|
|
if [lat1, lon1, lat2, lon2].any? { |coord| !coord.finite? }
|
|
Rails.logger.warn "Skipping distance calculation for points with invalid coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
|
next 0
|
|
end
|
|
|
|
# Check for valid latitude/longitude ranges
|
|
if lat1.abs > 90 || lat2.abs > 90 || lon1.abs > 180 || lon2.abs > 180
|
|
Rails.logger.warn "Skipping distance calculation for points with out-of-range coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
|
next 0
|
|
end
|
|
|
|
distance_km = Geocoder::Calculations.distance_between(
|
|
[lat1, lon1],
|
|
[lat2, lon2],
|
|
units: :km
|
|
)
|
|
|
|
# Check if Geocoder returned NaN or infinite value
|
|
if !distance_km.finite?
|
|
Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
|
next 0
|
|
end
|
|
|
|
distance_km * 1000 # Convert km to meters
|
|
rescue StandardError => e
|
|
Rails.logger.error "Error extracting coordinates from lonlat for points #{p1.id}, #{p2.id}: #{e.message}"
|
|
next 0
|
|
end
|
|
end
|
|
|
|
result = total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
|
|
|
# Final validation of result
|
|
if !result.finite?
|
|
Rails.logger.error "Final distance calculation resulted in invalid value (#{result}) for #{points.length} points"
|
|
return 0
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
private
|
|
|
|
def calculate_distance_for_relation(unit)
|
|
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
|
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
|
end
|
|
|
|
distance_in_meters = connection.select_value(<<-SQL.squish)
|
|
WITH points_with_previous AS (
|
|
SELECT
|
|
lonlat,
|
|
LAG(lonlat) OVER (ORDER BY timestamp) as prev_lonlat
|
|
FROM (#{to_sql}) AS points
|
|
)
|
|
SELECT COALESCE(
|
|
SUM(
|
|
ST_Distance(
|
|
lonlat::geography,
|
|
prev_lonlat::geography
|
|
)
|
|
),
|
|
0
|
|
)
|
|
FROM points_with_previous
|
|
WHERE prev_lonlat IS NOT NULL
|
|
SQL
|
|
|
|
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
|
end
|
|
|
|
def calculate_distance_for_array(points, unit = :km)
|
|
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
|
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
|
end
|
|
|
|
return 0 if points.length < 2
|
|
|
|
total_meters = calculate_batch_distances(points).sum
|
|
|
|
total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
|
end
|
|
|
|
def calculate_batch_distances(points)
|
|
return [] if points.length < 2
|
|
|
|
point_pairs = points.each_cons(2).to_a
|
|
return [] if point_pairs.empty?
|
|
|
|
# Create parameterized placeholders for VALUES clause using ? placeholders
|
|
values_placeholders = point_pairs.map do |_|
|
|
"(?, ST_GeomFromEWKT(?)::geography, ST_GeomFromEWKT(?)::geography)"
|
|
end.join(', ')
|
|
|
|
# Flatten parameters: [pair_id, lonlat1, lonlat2, pair_id, lonlat1, lonlat2, ...]
|
|
params = point_pairs.flat_map.with_index do |(p1, p2), index|
|
|
[index, p1.lonlat, p2.lonlat]
|
|
end
|
|
|
|
# Single query to calculate all distances using parameterized query
|
|
sql_with_params = ActiveRecord::Base.sanitize_sql_array([<<-SQL.squish] + params)
|
|
WITH point_pairs AS (
|
|
SELECT
|
|
pair_id,
|
|
point1,
|
|
point2
|
|
FROM (VALUES #{values_placeholders}) AS t(pair_id, point1, point2)
|
|
)
|
|
SELECT
|
|
pair_id,
|
|
ST_Distance(point1, point2) as distance_meters
|
|
FROM point_pairs
|
|
ORDER BY pair_id
|
|
SQL
|
|
|
|
results = connection.select_all(sql_with_params)
|
|
|
|
# Return array of distances in meters
|
|
results.map { |row| row['distance_meters'].to_f }
|
|
end
|
|
end
|
|
|
|
def distance_to(other_point, unit = :km)
|
|
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
|
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
|
end
|
|
|
|
other_lonlat = extract_point(other_point)
|
|
return nil if other_lonlat.nil?
|
|
|
|
# Calculate distance in meters using PostGIS
|
|
distance_in_meters = self.class.connection.select_value(<<-SQL.squish)
|
|
SELECT ST_Distance(
|
|
ST_GeomFromEWKT('#{lonlat}')::geography,
|
|
ST_GeomFromEWKT('#{other_lonlat}')::geography
|
|
)
|
|
SQL
|
|
|
|
# Convert to requested unit
|
|
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
|
end
|
|
|
|
# In-memory distance calculation using Geocoder (no SQL dependency)
|
|
def distance_to_geocoder(other_point, unit = :km)
|
|
unless ::DISTANCE_UNITS.key?(unit.to_sym)
|
|
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
|
|
end
|
|
|
|
begin
|
|
# Extract coordinates from lonlat (source of truth) for current point
|
|
if lonlat.nil?
|
|
Rails.logger.warn "Cannot calculate distance: current point has nil lonlat"
|
|
return 0
|
|
end
|
|
|
|
current_lat, current_lon = lat, lon
|
|
|
|
other_lat, other_lon = case other_point
|
|
when Array
|
|
[other_point[0], other_point[1]]
|
|
else
|
|
# For other Point objects, extract from their lonlat too
|
|
if other_point.respond_to?(:lonlat) && other_point.lonlat.nil?
|
|
Rails.logger.warn "Cannot calculate distance: other point has nil lonlat"
|
|
return 0
|
|
end
|
|
[other_point.lat, other_point.lon]
|
|
end
|
|
|
|
# Check for nil coordinates extracted from lonlat
|
|
if current_lat.nil? || current_lon.nil? || other_lat.nil? || other_lon.nil?
|
|
Rails.logger.warn "Cannot calculate distance: nil coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
|
return 0
|
|
end
|
|
|
|
# Check for NaN or infinite coordinates
|
|
coords = [current_lat, current_lon, other_lat, other_lon]
|
|
if coords.any? { |coord| !coord.finite? }
|
|
Rails.logger.warn "Cannot calculate distance: invalid coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
|
return 0
|
|
end
|
|
|
|
# Check for valid latitude/longitude ranges
|
|
if current_lat.abs > 90 || other_lat.abs > 90 || current_lon.abs > 180 || other_lon.abs > 180
|
|
Rails.logger.warn "Cannot calculate distance: out-of-range coordinates - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
|
return 0
|
|
end
|
|
|
|
distance_km = Geocoder::Calculations.distance_between(
|
|
[current_lat, current_lon],
|
|
[other_lat, other_lon],
|
|
units: :km
|
|
)
|
|
|
|
# Check if Geocoder returned valid distance
|
|
if !distance_km.finite?
|
|
Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
|
return 0
|
|
end
|
|
|
|
result = (distance_km * 1000).to_f / ::DISTANCE_UNITS[unit.to_sym]
|
|
|
|
# Final validation
|
|
if !result.finite?
|
|
Rails.logger.error "Final distance calculation resulted in invalid value (#{result})"
|
|
return 0
|
|
end
|
|
|
|
result
|
|
rescue StandardError => e
|
|
Rails.logger.error "Error calculating distance from lonlat: #{e.message}"
|
|
0
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def extract_point(point)
|
|
case point
|
|
when Array
|
|
unless point.length == 2
|
|
raise ArgumentError,
|
|
'Coordinates array must contain exactly 2 elements [latitude, longitude]'
|
|
end
|
|
|
|
RGeo::Geographic.spherical_factory(srid: 4326).point(point[1], point[0])
|
|
when self.class
|
|
point.lonlat
|
|
end
|
|
end
|
|
end
|