mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01: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
|
||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
before_action :validate_bbox_params, except: [:bounds]
|
||||
before_action :set_user_and_dates
|
||||
|
||||
def index
|
||||
# Try to use pre-calculated hexagon centers from stats
|
||||
if @stat&.hexagon_centers.present?
|
||||
result = build_hexagons_from_centers(@stat.hexagon_centers)
|
||||
Rails.logger.debug "Using pre-calculated hexagon centers: #{@stat.hexagon_centers.size} centers"
|
||||
return render json: result
|
||||
end
|
||||
result = Maps::HexagonRequestHandler.call(
|
||||
params: params,
|
||||
current_api_user: current_api_user
|
||||
)
|
||||
|
||||
# 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
|
||||
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,
|
||||
Maps::HexagonGrid::InvalidCoordinatesError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
|
|
@ -44,161 +25,41 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
end
|
||||
|
||||
def bounds
|
||||
# Get the bounding box of user's points for the date range
|
||||
return render json: { error: 'No user found' }, status: :not_found unless @target_user
|
||||
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
|
||||
context = Maps::HexagonContextResolver.call(
|
||||
params: params,
|
||||
current_api_user: current_api_user
|
||||
)
|
||||
|
||||
# Convert dates to timestamps (handle both string and timestamp formats)
|
||||
begin
|
||||
start_timestamp = coerce_date(@start_date)
|
||||
end_timestamp = coerce_date(@end_date)
|
||||
rescue ArgumentError => e
|
||||
return render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
result = Maps::BoundsCalculator.call(
|
||||
target_user: context[:target_user],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
)
|
||||
|
||||
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
|
||||
point_count = points_relation.count
|
||||
|
||||
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
|
||||
}
|
||||
if result[:success]
|
||||
render json: result[:data]
|
||||
else
|
||||
render json: {
|
||||
error: 'No data found for the specified date range',
|
||||
point_count: 0
|
||||
error: result[:error],
|
||||
point_count: result[:point_count]
|
||||
}, status: :not_found
|
||||
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
|
||||
|
||||
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
|
||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||
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
|
||||
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
|
||||
end
|
||||
|
|
@ -217,23 +78,4 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
error: "Missing required parameters: #{missing_params.join(', ')}"
|
||||
}, status: :bad_request
|
||||
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
|
||||
|
|
|
|||
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