mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Extract logic to service classes
This commit is contained in:
parent
8c45404420
commit
eb16959b9a
13 changed files with 1134 additions and 187 deletions
|
|
@ -3,37 +3,18 @@
|
||||||
class Api::V1::Maps::HexagonsController < ApiController
|
class Api::V1::Maps::HexagonsController < ApiController
|
||||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||||
before_action :validate_bbox_params, except: [:bounds]
|
before_action :validate_bbox_params, except: [:bounds]
|
||||||
before_action :set_user_and_dates
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# Try to use pre-calculated hexagon centers from stats
|
result = Maps::HexagonRequestHandler.call(
|
||||||
if @stat&.hexagon_centers.present?
|
params: params,
|
||||||
result = build_hexagons_from_centers(@stat.hexagon_centers)
|
current_api_user: current_api_user
|
||||||
Rails.logger.debug "Using pre-calculated hexagon centers: #{@stat.hexagon_centers.size} centers"
|
)
|
||||||
return render json: result
|
|
||||||
end
|
|
||||||
|
|
||||||
# Handle legacy "area too large" entries - recalculate them now that we can handle large areas
|
|
||||||
if @stat&.hexagon_centers&.dig('area_too_large')
|
|
||||||
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{@stat.id}"
|
|
||||||
|
|
||||||
# Trigger recalculation
|
|
||||||
service = Stats::CalculateMonth.new(@target_user.id, @stat.year, @stat.month)
|
|
||||||
new_centers = service.send(:calculate_hexagon_centers)
|
|
||||||
|
|
||||||
if new_centers && !new_centers.dig(:area_too_large)
|
|
||||||
@stat.update(hexagon_centers: new_centers)
|
|
||||||
result = build_hexagons_from_centers(new_centers)
|
|
||||||
Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
|
|
||||||
return render json: result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fall back to on-the-fly calculation for legacy/missing data
|
|
||||||
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
|
|
||||||
result = Maps::HexagonGrid.new(hexagon_params).call
|
|
||||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
|
||||||
render json: result
|
render json: result
|
||||||
|
rescue Maps::HexagonContextResolver::SharedStatsNotFoundError => e
|
||||||
|
render json: { error: e.message }, status: :not_found
|
||||||
|
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
|
||||||
|
render json: { error: e.message }, status: :bad_request
|
||||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||||
Maps::HexagonGrid::InvalidCoordinatesError => e
|
Maps::HexagonGrid::InvalidCoordinatesError => e
|
||||||
render json: { error: e.message }, status: :bad_request
|
render json: { error: e.message }, status: :bad_request
|
||||||
|
|
@ -44,161 +25,41 @@ class Api::V1::Maps::HexagonsController < ApiController
|
||||||
end
|
end
|
||||||
|
|
||||||
def bounds
|
def bounds
|
||||||
# Get the bounding box of user's points for the date range
|
context = Maps::HexagonContextResolver.call(
|
||||||
return render json: { error: 'No user found' }, status: :not_found unless @target_user
|
params: params,
|
||||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
current_api_user: current_api_user
|
||||||
|
)
|
||||||
|
|
||||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
result = Maps::BoundsCalculator.call(
|
||||||
begin
|
target_user: context[:target_user],
|
||||||
start_timestamp = coerce_date(@start_date)
|
start_date: context[:start_date],
|
||||||
end_timestamp = coerce_date(@end_date)
|
end_date: context[:end_date]
|
||||||
rescue ArgumentError => e
|
)
|
||||||
return render json: { error: e.message }, status: :bad_request
|
|
||||||
end
|
|
||||||
|
|
||||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
if result[:success]
|
||||||
point_count = points_relation.count
|
render json: result[:data]
|
||||||
|
|
||||||
if point_count.positive?
|
|
||||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
|
||||||
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
|
||||||
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
|
||||||
FROM points
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND timestamp BETWEEN $2 AND $3",
|
|
||||||
'bounds_query',
|
|
||||||
[@target_user.id, start_timestamp, end_timestamp]
|
|
||||||
).first
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
min_lat: bounds_result['min_lat'].to_f,
|
|
||||||
max_lat: bounds_result['max_lat'].to_f,
|
|
||||||
min_lng: bounds_result['min_lng'].to_f,
|
|
||||||
max_lng: bounds_result['max_lng'].to_f,
|
|
||||||
point_count: point_count
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
render json: {
|
render json: {
|
||||||
error: 'No data found for the specified date range',
|
error: result[:error],
|
||||||
point_count: 0
|
point_count: result[:point_count]
|
||||||
}, status: :not_found
|
}, status: :not_found
|
||||||
end
|
end
|
||||||
|
rescue Maps::HexagonContextResolver::SharedStatsNotFoundError => e
|
||||||
|
render json: { error: e.message }, status: :not_found
|
||||||
|
rescue Maps::BoundsCalculator::NoUserFoundError => e
|
||||||
|
render json: { error: e.message }, status: :not_found
|
||||||
|
rescue Maps::BoundsCalculator::NoDateRangeError => e
|
||||||
|
render json: { error: e.message }, status: :bad_request
|
||||||
|
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
|
||||||
|
render json: { error: e.message }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_hexagons_from_centers(centers)
|
|
||||||
# Convert stored centers back to hexagon polygons
|
|
||||||
# Each center is [lng, lat, earliest_timestamp, latest_timestamp]
|
|
||||||
hexagon_features = centers.map.with_index do |center, index|
|
|
||||||
lng, lat, earliest, latest = center
|
|
||||||
|
|
||||||
# Generate hexagon polygon from center point (1000m hexagons)
|
|
||||||
hexagon_geojson = generate_hexagon_polygon(lng, lat, 1000)
|
|
||||||
|
|
||||||
{
|
|
||||||
type: 'Feature',
|
|
||||||
id: index + 1,
|
|
||||||
geometry: hexagon_geojson,
|
|
||||||
properties: {
|
|
||||||
hex_id: index + 1,
|
|
||||||
hex_size: 1000,
|
|
||||||
earliest_point: earliest ? Time.zone.at(earliest).iso8601 : nil,
|
|
||||||
latest_point: latest ? Time.zone.at(latest).iso8601 : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
{
|
|
||||||
'type' => 'FeatureCollection',
|
|
||||||
'features' => hexagon_features,
|
|
||||||
'metadata' => {
|
|
||||||
'hex_size_m' => 1000,
|
|
||||||
'count' => hexagon_features.count,
|
|
||||||
'user_id' => @target_user.id,
|
|
||||||
'pre_calculated' => true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_hexagon_polygon(center_lng, center_lat, size_meters)
|
|
||||||
# Generate hexagon vertices around center point
|
|
||||||
# PostGIS ST_HexagonGrid uses size_meters as the edge-to-edge distance (width/flat-to-flat)
|
|
||||||
# For a regular hexagon with width = size_meters:
|
|
||||||
# - Width (edge to edge) = size_meters
|
|
||||||
# - Radius (center to vertex) = width / √3 ≈ size_meters * 0.577
|
|
||||||
# - Edge length ≈ radius ≈ size_meters * 0.577
|
|
||||||
|
|
||||||
radius_meters = size_meters / Math.sqrt(2.7) # Convert width to radius
|
|
||||||
|
|
||||||
# Convert meter radius to degrees (rough approximation)
|
|
||||||
# 1 degree latitude ≈ 111,111 meters
|
|
||||||
# 1 degree longitude ≈ 111,111 * cos(latitude) meters
|
|
||||||
lat_degree_in_meters = 111_111.0
|
|
||||||
lng_degree_in_meters = lat_degree_in_meters * Math.cos(center_lat * Math::PI / 180)
|
|
||||||
|
|
||||||
radius_lat_degrees = radius_meters / lat_degree_in_meters
|
|
||||||
radius_lng_degrees = radius_meters / lng_degree_in_meters
|
|
||||||
|
|
||||||
vertices = []
|
|
||||||
6.times do |i|
|
|
||||||
# Calculate angle for each vertex (60 degrees apart, starting from 0)
|
|
||||||
angle = (i * 60) * Math::PI / 180
|
|
||||||
|
|
||||||
# Calculate vertex position
|
|
||||||
lat_offset = radius_lat_degrees * Math.sin(angle)
|
|
||||||
lng_offset = radius_lng_degrees * Math.cos(angle)
|
|
||||||
|
|
||||||
vertices << [center_lng + lng_offset, center_lat + lat_offset]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Close the polygon by adding the first vertex at the end
|
|
||||||
vertices << vertices.first
|
|
||||||
|
|
||||||
{
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [vertices]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def bbox_params
|
def bbox_params
|
||||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||||
end
|
end
|
||||||
|
|
||||||
def hexagon_params
|
|
||||||
bbox_params.merge(
|
|
||||||
user_id: @target_user&.id,
|
|
||||||
start_date: @start_date,
|
|
||||||
end_date: @end_date
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_user_and_dates
|
|
||||||
return set_public_sharing_context if params[:uuid].present?
|
|
||||||
|
|
||||||
set_authenticated_context
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_public_sharing_context
|
|
||||||
@stat = Stat.find_by(sharing_uuid: params[:uuid])
|
|
||||||
|
|
||||||
unless @stat&.public_accessible?
|
|
||||||
render json: {
|
|
||||||
error: 'Shared stats not found or no longer available'
|
|
||||||
}, status: :not_found and return
|
|
||||||
end
|
|
||||||
|
|
||||||
@target_user = @stat.user
|
|
||||||
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day.iso8601
|
|
||||||
@end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_authenticated_context
|
|
||||||
@target_user = current_api_user
|
|
||||||
@start_date = params[:start_date]
|
|
||||||
@end_date = params[:end_date]
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_service_error
|
def handle_service_error
|
||||||
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
|
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
|
||||||
end
|
end
|
||||||
|
|
@ -217,23 +78,4 @@ class Api::V1::Maps::HexagonsController < ApiController
|
||||||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||||
}, status: :bad_request
|
}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
def coerce_date(param)
|
|
||||||
case param
|
|
||||||
when String
|
|
||||||
# Check if it's a numeric string (timestamp) or date string
|
|
||||||
if param.match?(/^\d+$/)
|
|
||||||
param.to_i
|
|
||||||
else
|
|
||||||
Time.parse(param).to_i
|
|
||||||
end
|
|
||||||
when Integer
|
|
||||||
param
|
|
||||||
else
|
|
||||||
param.to_i
|
|
||||||
end
|
|
||||||
rescue ArgumentError => e
|
|
||||||
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
|
||||||
raise ArgumentError, "Invalid date format: #{param}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
74
app/services/maps/bounds_calculator.rb
Normal file
74
app/services/maps/bounds_calculator.rb
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class BoundsCalculator
|
||||||
|
class NoUserFoundError < StandardError; end
|
||||||
|
class NoDateRangeError < StandardError; end
|
||||||
|
class NoDataFoundError < StandardError; end
|
||||||
|
|
||||||
|
def self.call(target_user:, start_date:, end_date:)
|
||||||
|
new(target_user: target_user, start_date: start_date, end_date: end_date).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(target_user:, start_date:, end_date:)
|
||||||
|
@target_user = target_user
|
||||||
|
@start_date = start_date
|
||||||
|
@end_date = end_date
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
validate_inputs!
|
||||||
|
|
||||||
|
start_timestamp = Maps::DateParameterCoercer.call(@start_date)
|
||||||
|
end_timestamp = Maps::DateParameterCoercer.call(@end_date)
|
||||||
|
|
||||||
|
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||||
|
point_count = points_relation.count
|
||||||
|
|
||||||
|
return build_no_data_response if point_count.zero?
|
||||||
|
|
||||||
|
bounds_result = execute_bounds_query(start_timestamp, end_timestamp)
|
||||||
|
build_success_response(bounds_result, point_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_inputs!
|
||||||
|
raise NoUserFoundError, 'No user found' unless @target_user
|
||||||
|
raise NoDateRangeError, 'No date range specified' unless @start_date && @end_date
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute_bounds_query(start_timestamp, end_timestamp)
|
||||||
|
ActiveRecord::Base.connection.exec_query(
|
||||||
|
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
|
||||||
|
MIN(longitude) as min_lng, MAX(longitude) as max_lng
|
||||||
|
FROM points
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND timestamp BETWEEN $2 AND $3",
|
||||||
|
'bounds_query',
|
||||||
|
[@target_user.id, start_timestamp, end_timestamp]
|
||||||
|
).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_success_response(bounds_result, point_count)
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
min_lat: bounds_result['min_lat'].to_f,
|
||||||
|
max_lat: bounds_result['max_lat'].to_f,
|
||||||
|
min_lng: bounds_result['min_lng'].to_f,
|
||||||
|
max_lng: bounds_result['max_lng'].to_f,
|
||||||
|
point_count: point_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_no_data_response
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'No data found for the specified date range',
|
||||||
|
point_count: 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
42
app/services/maps/date_parameter_coercer.rb
Normal file
42
app/services/maps/date_parameter_coercer.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class DateParameterCoercer
|
||||||
|
class InvalidDateFormatError < StandardError; end
|
||||||
|
|
||||||
|
def self.call(param)
|
||||||
|
new(param).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(param)
|
||||||
|
@param = param
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
coerce_date(@param)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :param
|
||||||
|
|
||||||
|
def coerce_date(param)
|
||||||
|
case param
|
||||||
|
when String
|
||||||
|
# Check if it's a numeric string (timestamp) or date string
|
||||||
|
if param.match?(/^\d+$/)
|
||||||
|
param.to_i
|
||||||
|
else
|
||||||
|
Time.parse(param).to_i
|
||||||
|
end
|
||||||
|
when Integer
|
||||||
|
param
|
||||||
|
else
|
||||||
|
param.to_i
|
||||||
|
end
|
||||||
|
rescue ArgumentError => e
|
||||||
|
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
|
||||||
|
raise InvalidDateFormatError, "Invalid date format: #{param}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
104
app/services/maps/hexagon_center_manager.rb
Normal file
104
app/services/maps/hexagon_center_manager.rb
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class HexagonCenterManager
|
||||||
|
def self.call(stat:, target_user:)
|
||||||
|
new(stat: stat, target_user: target_user).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(stat:, target_user:)
|
||||||
|
@stat = stat
|
||||||
|
@target_user = target_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return build_response_from_centers if pre_calculated_centers_available?
|
||||||
|
return handle_legacy_area_too_large if legacy_area_too_large?
|
||||||
|
|
||||||
|
nil # No pre-calculated data available
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :stat, :target_user
|
||||||
|
|
||||||
|
def pre_calculated_centers_available?
|
||||||
|
return false unless stat&.hexagon_centers.present?
|
||||||
|
|
||||||
|
# Handle legacy hash format
|
||||||
|
if stat.hexagon_centers.is_a?(Hash)
|
||||||
|
!stat.hexagon_centers['area_too_large']
|
||||||
|
else
|
||||||
|
# Handle array format (actual hexagon centers)
|
||||||
|
stat.hexagon_centers.is_a?(Array) && stat.hexagon_centers.any?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def legacy_area_too_large?
|
||||||
|
stat&.hexagon_centers.is_a?(Hash) && stat.hexagon_centers['area_too_large']
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_response_from_centers
|
||||||
|
centers = stat.hexagon_centers
|
||||||
|
Rails.logger.debug "Using pre-calculated hexagon centers: #{centers.size} centers"
|
||||||
|
|
||||||
|
result = build_hexagons_from_centers(centers)
|
||||||
|
{ success: true, data: result, pre_calculated: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_legacy_area_too_large
|
||||||
|
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{stat.id}"
|
||||||
|
|
||||||
|
# Trigger recalculation
|
||||||
|
service = Stats::CalculateMonth.new(target_user.id, stat.year, stat.month)
|
||||||
|
new_centers = service.send(:calculate_hexagon_centers)
|
||||||
|
|
||||||
|
if new_centers && new_centers.is_a?(Array)
|
||||||
|
stat.update(hexagon_centers: new_centers)
|
||||||
|
result = build_hexagons_from_centers(new_centers)
|
||||||
|
Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
|
||||||
|
return { success: true, data: result, pre_calculated: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
nil # Recalculation failed or still too large
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_hexagons_from_centers(centers)
|
||||||
|
# Convert stored centers back to hexagon polygons
|
||||||
|
# Each center is [lng, lat, earliest_timestamp, latest_timestamp]
|
||||||
|
hexagon_features = centers.map.with_index do |center, index|
|
||||||
|
lng, lat, earliest, latest = center
|
||||||
|
|
||||||
|
# Generate hexagon polygon from center point (1000m hexagons)
|
||||||
|
hexagon_geojson = Maps::HexagonPolygonGenerator.call(
|
||||||
|
center_lng: lng,
|
||||||
|
center_lat: lat,
|
||||||
|
size_meters: 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
'type' => 'Feature',
|
||||||
|
'id' => index + 1,
|
||||||
|
'geometry' => hexagon_geojson,
|
||||||
|
'properties' => {
|
||||||
|
'hex_id' => index + 1,
|
||||||
|
'hex_size' => 1000,
|
||||||
|
'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
|
||||||
|
'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
'type' => 'FeatureCollection',
|
||||||
|
'features' => hexagon_features,
|
||||||
|
'metadata' => {
|
||||||
|
'hex_size_m' => 1000,
|
||||||
|
'count' => hexagon_features.count,
|
||||||
|
'user_id' => target_user.id,
|
||||||
|
'pre_calculated' => true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
58
app/services/maps/hexagon_context_resolver.rb
Normal file
58
app/services/maps/hexagon_context_resolver.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class HexagonContextResolver
|
||||||
|
class SharedStatsNotFoundError < StandardError; end
|
||||||
|
|
||||||
|
def self.call(params:, current_api_user: nil)
|
||||||
|
new(params: params, current_api_user: current_api_user).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(params:, current_api_user: nil)
|
||||||
|
@params = params
|
||||||
|
@current_api_user = current_api_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return resolve_public_sharing_context if public_sharing_request?
|
||||||
|
|
||||||
|
resolve_authenticated_context
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :params, :current_api_user
|
||||||
|
|
||||||
|
def public_sharing_request?
|
||||||
|
params[:uuid].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_public_sharing_context
|
||||||
|
stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||||
|
|
||||||
|
unless stat&.public_accessible?
|
||||||
|
raise SharedStatsNotFoundError, 'Shared stats not found or no longer available'
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user = stat.user
|
||||||
|
start_date = Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601
|
||||||
|
end_date = Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601
|
||||||
|
|
||||||
|
{
|
||||||
|
target_user: target_user,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date,
|
||||||
|
stat: stat
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_authenticated_context
|
||||||
|
{
|
||||||
|
target_user: current_api_user,
|
||||||
|
start_date: params[:start_date],
|
||||||
|
end_date: params[:end_date],
|
||||||
|
stat: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
70
app/services/maps/hexagon_polygon_generator.rb
Normal file
70
app/services/maps/hexagon_polygon_generator.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class HexagonPolygonGenerator
|
||||||
|
DEFAULT_SIZE_METERS = 1000
|
||||||
|
|
||||||
|
def self.call(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
|
||||||
|
new(center_lng: center_lng, center_lat: center_lat, size_meters: size_meters).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
|
||||||
|
@center_lng = center_lng
|
||||||
|
@center_lat = center_lat
|
||||||
|
@size_meters = size_meters
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
generate_hexagon_polygon
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :center_lng, :center_lat, :size_meters
|
||||||
|
|
||||||
|
def generate_hexagon_polygon
|
||||||
|
# Generate hexagon vertices around center point
|
||||||
|
# PostGIS ST_HexagonGrid uses size_meters as the edge-to-edge distance (width/flat-to-flat)
|
||||||
|
# For a regular hexagon with width = size_meters:
|
||||||
|
# - Width (edge to edge) = size_meters
|
||||||
|
# - Radius (center to vertex) = width / √3 ≈ size_meters * 0.577
|
||||||
|
# - Edge length ≈ radius ≈ size_meters * 0.577
|
||||||
|
|
||||||
|
radius_meters = size_meters / Math.sqrt(2.7) # Convert width to radius
|
||||||
|
|
||||||
|
# Convert meter radius to degrees (rough approximation)
|
||||||
|
# 1 degree latitude ≈ 111,111 meters
|
||||||
|
# 1 degree longitude ≈ 111,111 * cos(latitude) meters
|
||||||
|
lat_degree_in_meters = 111_111.0
|
||||||
|
lng_degree_in_meters = lat_degree_in_meters * Math.cos(center_lat * Math::PI / 180)
|
||||||
|
|
||||||
|
radius_lat_degrees = radius_meters / lat_degree_in_meters
|
||||||
|
radius_lng_degrees = radius_meters / lng_degree_in_meters
|
||||||
|
|
||||||
|
vertices = build_vertices(radius_lat_degrees, radius_lng_degrees)
|
||||||
|
|
||||||
|
{
|
||||||
|
'type' => 'Polygon',
|
||||||
|
'coordinates' => [vertices]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_vertices(radius_lat_degrees, radius_lng_degrees)
|
||||||
|
vertices = []
|
||||||
|
6.times do |i|
|
||||||
|
# Calculate angle for each vertex (60 degrees apart, starting from 0)
|
||||||
|
angle = (i * 60) * Math::PI / 180
|
||||||
|
|
||||||
|
# Calculate vertex position
|
||||||
|
lat_offset = radius_lat_degrees * Math.sin(angle)
|
||||||
|
lng_offset = radius_lng_degrees * Math.cos(angle)
|
||||||
|
|
||||||
|
vertices << [center_lng + lng_offset, center_lat + lat_offset]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Close the polygon by adding the first vertex at the end
|
||||||
|
vertices << vertices.first
|
||||||
|
vertices
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/services/maps/hexagon_request_handler.rb
Normal file
62
app/services/maps/hexagon_request_handler.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Maps
|
||||||
|
class HexagonRequestHandler
|
||||||
|
def self.call(params:, current_api_user: nil)
|
||||||
|
new(params: params, current_api_user: current_api_user).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(params:, current_api_user: nil)
|
||||||
|
@params = params
|
||||||
|
@current_api_user = current_api_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
context = resolve_context
|
||||||
|
|
||||||
|
# Try to use pre-calculated hexagon centers first
|
||||||
|
if context[:stat]
|
||||||
|
cached_result = Maps::HexagonCenterManager.call(
|
||||||
|
stat: context[:stat],
|
||||||
|
target_user: context[:target_user]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cached_result[:data] if cached_result&.dig(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fall back to on-the-fly calculation
|
||||||
|
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
|
||||||
|
generate_hexagons_on_the_fly(context)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :params, :current_api_user
|
||||||
|
|
||||||
|
def resolve_context
|
||||||
|
Maps::HexagonContextResolver.call(
|
||||||
|
params: params,
|
||||||
|
current_api_user: current_api_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_hexagons_on_the_fly(context)
|
||||||
|
hexagon_params = build_hexagon_params(context)
|
||||||
|
result = Maps::HexagonGrid.new(hexagon_params).call
|
||||||
|
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_hexagon_params(context)
|
||||||
|
bbox_params.merge(
|
||||||
|
user_id: context[:target_user]&.id,
|
||||||
|
start_date: context[:start_date],
|
||||||
|
end_date: context[:end_date]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bbox_params
|
||||||
|
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
120
spec/services/maps/bounds_calculator_spec.rb
Normal file
120
spec/services/maps/bounds_calculator_spec.rb
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::BoundsCalculator do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:calculate_bounds) do
|
||||||
|
described_class.call(
|
||||||
|
target_user: target_user,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:target_user) { user }
|
||||||
|
let(:start_date) { '2024-06-01T00:00:00Z' }
|
||||||
|
let(:end_date) { '2024-06-30T23:59:59Z' }
|
||||||
|
|
||||||
|
context 'with valid user and date range' do
|
||||||
|
before do
|
||||||
|
# Create test points within the date range
|
||||||
|
create(:point, user:, latitude: 40.6, longitude: -74.1,
|
||||||
|
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
|
||||||
|
create(:point, user:, latitude: 40.8, longitude: -73.9,
|
||||||
|
timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
|
||||||
|
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||||
|
timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns success with bounds data' do
|
||||||
|
expect(calculate_bounds).to match({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
min_lat: 40.6,
|
||||||
|
max_lat: 40.8,
|
||||||
|
min_lng: -74.1,
|
||||||
|
max_lng: -73.9,
|
||||||
|
point_count: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no points in date range' do
|
||||||
|
before do
|
||||||
|
# Create points outside the date range
|
||||||
|
create(:point, user:, latitude: 40.7, longitude: -74.0,
|
||||||
|
timestamp: Time.new(2024, 5, 15, 10, 0).to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns failure with no data message' do
|
||||||
|
expect(calculate_bounds).to match({
|
||||||
|
success: false,
|
||||||
|
error: 'No data found for the specified date range',
|
||||||
|
point_count: 0
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no user' do
|
||||||
|
let(:target_user) { nil }
|
||||||
|
|
||||||
|
it 'raises NoUserFoundError' do
|
||||||
|
expect { calculate_bounds }.to raise_error(
|
||||||
|
Maps::BoundsCalculator::NoUserFoundError,
|
||||||
|
'No user found'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no start date' do
|
||||||
|
let(:start_date) { nil }
|
||||||
|
|
||||||
|
it 'raises NoDateRangeError' do
|
||||||
|
expect { calculate_bounds }.to raise_error(
|
||||||
|
Maps::BoundsCalculator::NoDateRangeError,
|
||||||
|
'No date range specified'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no end date' do
|
||||||
|
let(:end_date) { nil }
|
||||||
|
|
||||||
|
it 'raises NoDateRangeError' do
|
||||||
|
expect { calculate_bounds }.to raise_error(
|
||||||
|
Maps::BoundsCalculator::NoDateRangeError,
|
||||||
|
'No date range specified'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid date format' do
|
||||||
|
let(:start_date) { 'invalid-date' }
|
||||||
|
|
||||||
|
it 'raises InvalidDateFormatError' do
|
||||||
|
expect { calculate_bounds }.to raise_error(
|
||||||
|
Maps::DateParameterCoercer::InvalidDateFormatError
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with timestamp format dates' do
|
||||||
|
let(:start_date) { 1_717_200_000 }
|
||||||
|
let(:end_date) { 1_719_791_999 }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:point, user:, latitude: 41.0, longitude: -74.5,
|
||||||
|
timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles timestamp format correctly' do
|
||||||
|
result = calculate_bounds
|
||||||
|
expect(result[:success]).to be true
|
||||||
|
expect(result[:data][:point_count]).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
70
spec/services/maps/date_parameter_coercer_spec.rb
Normal file
70
spec/services/maps/date_parameter_coercer_spec.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::DateParameterCoercer do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:coerce_date) { described_class.call(param) }
|
||||||
|
|
||||||
|
context 'with integer parameter' do
|
||||||
|
let(:param) { 1_717_200_000 }
|
||||||
|
|
||||||
|
it 'returns the integer unchanged' do
|
||||||
|
expect(coerce_date).to eq(1_717_200_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with numeric string parameter' do
|
||||||
|
let(:param) { '1717200000' }
|
||||||
|
|
||||||
|
it 'converts to integer' do
|
||||||
|
expect(coerce_date).to eq(1_717_200_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with ISO date string parameter' do
|
||||||
|
let(:param) { '2024-06-01T00:00:00Z' }
|
||||||
|
|
||||||
|
it 'parses and converts to timestamp' do
|
||||||
|
expected_timestamp = Time.parse('2024-06-01T00:00:00Z').to_i
|
||||||
|
expect(coerce_date).to eq(expected_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with date string parameter' do
|
||||||
|
let(:param) { '2024-06-01' }
|
||||||
|
|
||||||
|
it 'parses and converts to timestamp' do
|
||||||
|
expected_timestamp = Time.parse('2024-06-01').to_i
|
||||||
|
expect(coerce_date).to eq(expected_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid date string' do
|
||||||
|
let(:param) { 'invalid-date' }
|
||||||
|
|
||||||
|
it 'raises InvalidDateFormatError' do
|
||||||
|
expect { coerce_date }.to raise_error(
|
||||||
|
Maps::DateParameterCoercer::InvalidDateFormatError,
|
||||||
|
'Invalid date format: invalid-date'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with nil parameter' do
|
||||||
|
let(:param) { nil }
|
||||||
|
|
||||||
|
it 'converts to 0' do
|
||||||
|
expect(coerce_date).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with float parameter' do
|
||||||
|
let(:param) { 1_717_200_000.5 }
|
||||||
|
|
||||||
|
it 'converts to integer' do
|
||||||
|
expect(coerce_date).to eq(1_717_200_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
129
spec/services/maps/hexagon_center_manager_spec.rb
Normal file
129
spec/services/maps/hexagon_center_manager_spec.rb
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::HexagonCenterManager do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:manage_centers) do
|
||||||
|
described_class.call(
|
||||||
|
stat: stat,
|
||||||
|
target_user: target_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:target_user) { user }
|
||||||
|
|
||||||
|
context 'with pre-calculated hexagon centers' do
|
||||||
|
let(:pre_calculated_centers) do
|
||||||
|
[
|
||||||
|
[-74.0, 40.7, 1_717_200_000, 1_717_203_600], # lng, lat, earliest, latest timestamps
|
||||||
|
[-74.01, 40.71, 1_717_210_000, 1_717_213_600],
|
||||||
|
[-74.02, 40.72, 1_717_220_000, 1_717_223_600]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: pre_calculated_centers) }
|
||||||
|
|
||||||
|
it 'returns success with pre-calculated data' do
|
||||||
|
result = manage_centers
|
||||||
|
|
||||||
|
expect(result[:success]).to be true
|
||||||
|
expect(result[:pre_calculated]).to be true
|
||||||
|
expect(result[:data]['type']).to eq('FeatureCollection')
|
||||||
|
expect(result[:data]['features'].length).to eq(3)
|
||||||
|
expect(result[:data]['metadata']['pre_calculated']).to be true
|
||||||
|
expect(result[:data]['metadata']['count']).to eq(3)
|
||||||
|
expect(result[:data]['metadata']['user_id']).to eq(target_user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates proper hexagon features from centers' do
|
||||||
|
result = manage_centers
|
||||||
|
features = result[:data]['features']
|
||||||
|
|
||||||
|
features.each_with_index do |feature, index|
|
||||||
|
expect(feature['type']).to eq('Feature')
|
||||||
|
expect(feature['id']).to eq(index + 1)
|
||||||
|
expect(feature['geometry']['type']).to eq('Polygon')
|
||||||
|
expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing
|
||||||
|
|
||||||
|
properties = feature['properties']
|
||||||
|
expect(properties['hex_id']).to eq(index + 1)
|
||||||
|
expect(properties['hex_size']).to eq(1000)
|
||||||
|
expect(properties['earliest_point']).to be_present
|
||||||
|
expect(properties['latest_point']).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with legacy area_too_large flag' do
|
||||||
|
let(:stat) do
|
||||||
|
create(:stat, user:, year: 2024, month: 6, hexagon_centers: { 'area_too_large' => true })
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock the Stats::CalculateMonth service
|
||||||
|
allow_any_instance_of(Stats::CalculateMonth).to receive(:calculate_hexagon_centers)
|
||||||
|
.and_return(new_centers)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when recalculation succeeds' do
|
||||||
|
let(:new_centers) do
|
||||||
|
[
|
||||||
|
[-74.0, 40.7, 1_717_200_000, 1_717_203_600],
|
||||||
|
[-74.01, 40.71, 1_717_210_000, 1_717_213_600]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recalculates and updates the stat' do
|
||||||
|
expect(stat).to receive(:update).with(hexagon_centers: new_centers)
|
||||||
|
|
||||||
|
result = manage_centers
|
||||||
|
|
||||||
|
expect(result[:success]).to be true
|
||||||
|
expect(result[:pre_calculated]).to be true
|
||||||
|
expect(result[:data]['features'].length).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when recalculation fails' do
|
||||||
|
let(:new_centers) { nil }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(manage_centers).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when recalculation returns area_too_large again' do
|
||||||
|
let(:new_centers) { { area_too_large: true } }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(manage_centers).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no stat' do
|
||||||
|
let(:stat) { nil }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(manage_centers).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with stat but no hexagon_centers' do
|
||||||
|
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: nil) }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(manage_centers).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty hexagon_centers' do
|
||||||
|
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: []) }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(manage_centers).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
102
spec/services/maps/hexagon_context_resolver_spec.rb
Normal file
102
spec/services/maps/hexagon_context_resolver_spec.rb
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::HexagonContextResolver do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:resolve_context) do
|
||||||
|
described_class.call(
|
||||||
|
params: params,
|
||||||
|
current_api_user: current_api_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:current_api_user) { user }
|
||||||
|
|
||||||
|
context 'with authenticated user (no UUID)' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
start_date: '2024-06-01T00:00:00Z',
|
||||||
|
end_date: '2024-06-30T23:59:59Z'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolves authenticated context' do
|
||||||
|
result = resolve_context
|
||||||
|
|
||||||
|
expect(result).to match({
|
||||||
|
target_user: current_api_user,
|
||||||
|
start_date: '2024-06-01T00:00:00Z',
|
||||||
|
end_date: '2024-06-30T23:59:59Z',
|
||||||
|
stat: nil
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with public sharing UUID' do
|
||||||
|
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
|
||||||
|
let(:params) { { uuid: stat.sharing_uuid } }
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'resolves public sharing context' do
|
||||||
|
result = resolve_context
|
||||||
|
|
||||||
|
expect(result[:target_user]).to eq(user)
|
||||||
|
expect(result[:stat]).to eq(stat)
|
||||||
|
expect(result[:start_date]).to eq('2024-06-01T00:00:00+00:00')
|
||||||
|
expect(result[:end_date]).to eq('2024-06-30T23:59:59+00:00')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid sharing UUID' do
|
||||||
|
let(:params) { { uuid: 'invalid-uuid' } }
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'raises SharedStatsNotFoundError' do
|
||||||
|
expect { resolve_context }.to raise_error(
|
||||||
|
Maps::HexagonContextResolver::SharedStatsNotFoundError,
|
||||||
|
'Shared stats not found or no longer available'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with expired sharing' do
|
||||||
|
let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
|
||||||
|
let(:params) { { uuid: stat.sharing_uuid } }
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'raises SharedStatsNotFoundError' do
|
||||||
|
expect { resolve_context }.to raise_error(
|
||||||
|
Maps::HexagonContextResolver::SharedStatsNotFoundError,
|
||||||
|
'Shared stats not found or no longer available'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with disabled sharing' do
|
||||||
|
let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
|
||||||
|
let(:params) { { uuid: stat.sharing_uuid } }
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'raises SharedStatsNotFoundError' do
|
||||||
|
expect { resolve_context }.to raise_error(
|
||||||
|
Maps::HexagonContextResolver::SharedStatsNotFoundError,
|
||||||
|
'Shared stats not found or no longer available'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with stat that does not exist' do
|
||||||
|
let(:params) { { uuid: 'non-existent-uuid' } }
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'raises SharedStatsNotFoundError' do
|
||||||
|
expect { resolve_context }.to raise_error(
|
||||||
|
Maps::HexagonContextResolver::SharedStatsNotFoundError,
|
||||||
|
'Shared stats not found or no longer available'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
99
spec/services/maps/hexagon_polygon_generator_spec.rb
Normal file
99
spec/services/maps/hexagon_polygon_generator_spec.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::HexagonPolygonGenerator do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:generate_polygon) do
|
||||||
|
described_class.call(
|
||||||
|
center_lng: center_lng,
|
||||||
|
center_lat: center_lat,
|
||||||
|
size_meters: size_meters
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:center_lng) { -74.0 }
|
||||||
|
let(:center_lat) { 40.7 }
|
||||||
|
let(:size_meters) { 1000 }
|
||||||
|
|
||||||
|
it 'returns a polygon geometry' do
|
||||||
|
result = generate_polygon
|
||||||
|
|
||||||
|
expect(result['type']).to eq('Polygon')
|
||||||
|
expect(result['coordinates']).to be_an(Array)
|
||||||
|
expect(result['coordinates'].length).to eq(1) # One ring
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do
|
||||||
|
result = generate_polygon
|
||||||
|
coordinates = result['coordinates'].first
|
||||||
|
|
||||||
|
expect(coordinates.length).to eq(7) # 6 vertices + closing vertex
|
||||||
|
expect(coordinates.first).to eq(coordinates.last) # Closed polygon
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates unique vertices' do
|
||||||
|
result = generate_polygon
|
||||||
|
coordinates = result['coordinates'].first
|
||||||
|
|
||||||
|
# Remove the closing vertex for uniqueness check
|
||||||
|
unique_vertices = coordinates[0..5]
|
||||||
|
expect(unique_vertices.uniq.length).to eq(6) # All vertices should be unique
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates vertices around the center point' do
|
||||||
|
result = generate_polygon
|
||||||
|
coordinates = result['coordinates'].first
|
||||||
|
|
||||||
|
# Check that all vertices are different from center
|
||||||
|
coordinates[0..5].each do |vertex|
|
||||||
|
lng, lat = vertex
|
||||||
|
expect(lng).not_to eq(center_lng)
|
||||||
|
expect(lat).not_to eq(center_lat)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with different size' do
|
||||||
|
let(:size_meters) { 500 }
|
||||||
|
|
||||||
|
it 'generates a smaller hexagon' do
|
||||||
|
small_result = generate_polygon
|
||||||
|
large_result = described_class.call(
|
||||||
|
center_lng: center_lng,
|
||||||
|
center_lat: center_lat,
|
||||||
|
size_meters: 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
# Small hexagon should have vertices closer to center than large hexagon
|
||||||
|
small_distance = calculate_distance_from_center(small_result['coordinates'].first.first)
|
||||||
|
large_distance = calculate_distance_from_center(large_result['coordinates'].first.first)
|
||||||
|
|
||||||
|
expect(small_distance).to be < large_distance
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with different center coordinates' do
|
||||||
|
let(:center_lng) { 13.4 } # Berlin
|
||||||
|
let(:center_lat) { 52.5 }
|
||||||
|
|
||||||
|
it 'generates hexagon around the new center' do
|
||||||
|
result = generate_polygon
|
||||||
|
coordinates = result[:coordinates].first
|
||||||
|
|
||||||
|
# Check that vertices are around the Berlin coordinates
|
||||||
|
avg_lng = coordinates[0..5].sum { |vertex| vertex[0] } / 6
|
||||||
|
avg_lat = coordinates[0..5].sum { |vertex| vertex[1] } / 6
|
||||||
|
|
||||||
|
expect(avg_lng).to be_within(0.01).of(center_lng)
|
||||||
|
expect(avg_lat).to be_within(0.01).of(center_lat)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def calculate_distance_from_center(vertex)
|
||||||
|
lng, lat = vertex
|
||||||
|
Math.sqrt((lng - center_lng)**2 + (lat - center_lat)**2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
175
spec/services/maps/hexagon_request_handler_spec.rb
Normal file
175
spec/services/maps/hexagon_request_handler_spec.rb
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Maps::HexagonRequestHandler do
|
||||||
|
describe '.call' do
|
||||||
|
subject(:handle_request) do
|
||||||
|
described_class.call(
|
||||||
|
params: params,
|
||||||
|
current_api_user: current_api_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:current_api_user) { user }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||||
|
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with authenticated user and bounding box params' do
|
||||||
|
let(:params) do
|
||||||
|
ActionController::Parameters.new({
|
||||||
|
min_lon: -74.1,
|
||||||
|
min_lat: 40.6,
|
||||||
|
max_lon: -73.9,
|
||||||
|
max_lat: 40.8,
|
||||||
|
hex_size: 1000,
|
||||||
|
start_date: '2024-06-01T00:00:00Z',
|
||||||
|
end_date: '2024-06-30T23:59:59Z'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create test points within the date range and bounding box
|
||||||
|
10.times do |i|
|
||||||
|
create(:point,
|
||||||
|
user:,
|
||||||
|
latitude: 40.7 + (i * 0.001),
|
||||||
|
longitude: -74.0 + (i * 0.001),
|
||||||
|
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns on-the-fly hexagon calculation' do
|
||||||
|
result = handle_request
|
||||||
|
|
||||||
|
expect(result).to be_a(Hash)
|
||||||
|
expect(result['type']).to eq('FeatureCollection')
|
||||||
|
expect(result['features']).to be_an(Array)
|
||||||
|
expect(result['metadata']).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with public sharing UUID and pre-calculated centers' do
|
||||||
|
let(:pre_calculated_centers) do
|
||||||
|
[
|
||||||
|
[-74.0, 40.7, 1_717_200_000, 1_717_203_600],
|
||||||
|
[-74.01, 40.71, 1_717_210_000, 1_717_213_600]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
let(:stat) do
|
||||||
|
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
|
||||||
|
hexagon_centers: pre_calculated_centers)
|
||||||
|
end
|
||||||
|
let(:params) do
|
||||||
|
ActionController::Parameters.new({
|
||||||
|
uuid: stat.sharing_uuid,
|
||||||
|
min_lon: -74.1,
|
||||||
|
min_lat: 40.6,
|
||||||
|
max_lon: -73.9,
|
||||||
|
max_lat: 40.8,
|
||||||
|
hex_size: 1000
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'returns pre-calculated hexagon data' do
|
||||||
|
result = handle_request
|
||||||
|
|
||||||
|
expect(result['type']).to eq('FeatureCollection')
|
||||||
|
expect(result['features'].length).to eq(2)
|
||||||
|
expect(result['metadata']['pre_calculated']).to be true
|
||||||
|
expect(result['metadata']['user_id']).to eq(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with public sharing UUID but no pre-calculated centers' do
|
||||||
|
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
|
||||||
|
let(:params) do
|
||||||
|
ActionController::Parameters.new({
|
||||||
|
uuid: stat.sharing_uuid,
|
||||||
|
min_lon: -74.1,
|
||||||
|
min_lat: 40.6,
|
||||||
|
max_lon: -73.9,
|
||||||
|
max_lat: 40.8,
|
||||||
|
hex_size: 1000
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create test points for the stat's month
|
||||||
|
5.times do |i|
|
||||||
|
create(:point,
|
||||||
|
user:,
|
||||||
|
latitude: 40.7 + (i * 0.001),
|
||||||
|
longitude: -74.0 + (i * 0.001),
|
||||||
|
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to on-the-fly calculation' do
|
||||||
|
result = handle_request
|
||||||
|
|
||||||
|
expect(result['type']).to eq('FeatureCollection')
|
||||||
|
expect(result['features']).to be_an(Array)
|
||||||
|
expect(result['metadata']).to be_present
|
||||||
|
expect(result['metadata']['pre_calculated']).to be_falsy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with legacy area_too_large that can be recalculated' do
|
||||||
|
let(:stat) do
|
||||||
|
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
|
||||||
|
hexagon_centers: { 'area_too_large' => true })
|
||||||
|
end
|
||||||
|
let(:params) do
|
||||||
|
ActionController::Parameters.new({
|
||||||
|
uuid: stat.sharing_uuid,
|
||||||
|
min_lon: -74.1,
|
||||||
|
min_lat: 40.6,
|
||||||
|
max_lon: -73.9,
|
||||||
|
max_lat: 40.8,
|
||||||
|
hex_size: 1000
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock successful recalculation
|
||||||
|
allow_any_instance_of(Stats::CalculateMonth).to receive(:calculate_hexagon_centers)
|
||||||
|
.and_return([[-74.0, 40.7, 1_717_200_000, 1_717_203_600]])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recalculates and returns pre-calculated data' do
|
||||||
|
expect(stat).to receive(:update).with(
|
||||||
|
hexagon_centers: [[-74.0, 40.7, 1_717_200_000, 1_717_203_600]]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = handle_request
|
||||||
|
|
||||||
|
expect(result['type']).to eq('FeatureCollection')
|
||||||
|
expect(result['features'].length).to eq(1)
|
||||||
|
expect(result['metadata']['pre_calculated']).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'error handling' do
|
||||||
|
let(:params) do
|
||||||
|
ActionController::Parameters.new({
|
||||||
|
uuid: 'invalid-uuid'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
let(:current_api_user) { nil }
|
||||||
|
|
||||||
|
it 'raises SharedStatsNotFoundError for invalid UUID' do
|
||||||
|
expect { handle_request }.to raise_error(
|
||||||
|
Maps::HexagonContextResolver::SharedStatsNotFoundError
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue