diff --git a/app/services/maps/bounds_calculator.rb b/app/services/maps/bounds_calculator.rb index 5824ae3a..f97e1b77 100644 --- a/app/services/maps/bounds_calculator.rb +++ b/app/services/maps/bounds_calculator.rb @@ -72,8 +72,6 @@ module Maps if param.match?(/^\d+$/) param.to_i else - # Use Time.parse for strict validation, then convert via Time.zone - parsed_time = Time.parse(param) # This will raise ArgumentError for invalid dates Time.zone.parse(param).to_i end when Integer diff --git a/app/services/maps/h3_hexagon_renderer.rb b/app/services/maps/h3_hexagon_renderer.rb deleted file mode 100644 index c710f6a7..00000000 --- a/app/services/maps/h3_hexagon_renderer.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -module Maps - class H3HexagonRenderer - def initialize(params:, user: nil, context: nil) - @params = params - @user = user - @context = context - end - - def call - context = @context || resolve_context - h3_data = get_h3_hexagon_data(context) - - return empty_feature_collection if h3_data.empty? - - convert_h3_to_geojson(h3_data) - end - - private - - attr_reader :params, :user, :context - - def get_h3_hexagon_data(context) - # For public sharing, get pre-calculated data from stat - if context[:stat]&.hexagon_centers.present? - hexagon_data = context[:stat].hexagon_centers - - # Check if this is old format (coordinates) or new format (H3 indexes) - if hexagon_data.first.is_a?(Array) && hexagon_data.first[0].is_a?(Float) - Rails.logger.debug "Found old coordinate format for stat #{context[:stat].id}, generating H3 on-the-fly" - return generate_h3_data_on_the_fly(context) - else - Rails.logger.debug "Using pre-calculated H3 data for stat #{context[:stat].id}" - return hexagon_data - end - end - - # For authenticated users, calculate on-the-fly if no pre-calculated data - Rails.logger.debug 'No pre-calculated H3 data, calculating on-the-fly' - generate_h3_data_on_the_fly(context) - end - - def generate_h3_data_on_the_fly(context) - start_date = parse_date_for_h3(context[:start_date]) - end_date = parse_date_for_h3(context[:end_date]) - h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6 - - # Use dummy year/month since we're only using the H3 calculation method - stats_service = Stats::CalculateMonth.new(context[:user]&.id, 2024, 1) - stats_service.calculate_h3_hexagon_centers( - user_id: context[:user]&.id, - start_date: start_date, - end_date: end_date, - h3_resolution: h3_resolution - ) - end - - def convert_h3_to_geojson(h3_data) - features = h3_data.map do |h3_record| - h3_index_string, point_count, earliest_timestamp, latest_timestamp = h3_record - - # Convert hex string back to H3 index - h3_index = h3_index_string.to_i(16) - - # Get hexagon boundary coordinates - boundary_coordinates = H3.to_boundary(h3_index) - - # Convert to GeoJSON polygon format (lng, lat) - polygon_coordinates = boundary_coordinates.map { |lat, lng| [lng, lat] } - polygon_coordinates << polygon_coordinates.first # Close the polygon - - { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [polygon_coordinates] - }, - properties: { - h3_index: h3_index_string, - point_count: point_count, - earliest_point: earliest_timestamp ? Time.at(earliest_timestamp).iso8601 : nil, - latest_point: latest_timestamp ? Time.at(latest_timestamp).iso8601 : nil, - center: H3.to_geo_coordinates(h3_index) # [lat, lng] - } - } - end - - { - type: 'FeatureCollection', - features: features, - metadata: { - hexagon_count: features.size, - total_points: features.sum { |f| f[:properties][:point_count] }, - source: 'h3' - } - } - end - - def empty_feature_collection - { - type: 'FeatureCollection', - features: [], - metadata: { - hexagon_count: 0, - total_points: 0, - source: 'h3' - } - } - end - - def parse_date_for_h3(date_param) - # If already a Time object (from public sharing context), return as-is - return date_param if date_param.is_a?(Time) - - # If it's a string ISO date, parse it directly to Time - return Time.zone.parse(date_param) if date_param.is_a?(String) - - # If it's an integer timestamp, convert to Time - return Time.zone.at(date_param) if date_param.is_a?(Integer) - - # For other cases, try coercing and converting - case date_param - when String - date_param.match?(/^\d+$/) ? Time.zone.at(date_param.to_i) : Time.zone.parse(date_param) - when Integer - Time.zone.at(date_param) - else - Time.zone.at(date_param.to_i) - end - rescue ArgumentError => e - Rails.logger.error "Invalid date format: #{date_param} - #{e.message}" - raise ArgumentError, "Invalid date format: #{date_param}" - end - end -end diff --git a/app/services/maps/hexagon_center_manager.rb b/app/services/maps/hexagon_center_manager.rb index 33177816..9c3d83be 100644 --- a/app/services/maps/hexagon_center_manager.rb +++ b/app/services/maps/hexagon_center_manager.rb @@ -2,10 +2,6 @@ module Maps class HexagonCenterManager - def self.call(stat:, user:) - new(stat: stat, user: user).call - end - def initialize(stat:, user:) @stat = stat @user = user @@ -86,11 +82,10 @@ module Maps end def generate_hexagon_geometry(lng, lat) - Maps::HexagonPolygonGenerator.call( + Maps::HexagonPolygonGenerator.new( center_lng: lng, - center_lat: lat, - size_meters: 1000 - ) + center_lat: lat + ).call end def build_hexagon_properties(index, earliest, latest) diff --git a/app/services/maps/hexagon_polygon_generator.rb b/app/services/maps/hexagon_polygon_generator.rb index 52c5a30e..b6700aab 100644 --- a/app/services/maps/hexagon_polygon_generator.rb +++ b/app/services/maps/hexagon_polygon_generator.rb @@ -2,37 +2,19 @@ module Maps class HexagonPolygonGenerator - DEFAULT_SIZE_METERS = 1000 - - def self.call(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS, use_h3: false, h3_resolution: 5) - new( - center_lng: center_lng, - center_lat: center_lat, - size_meters: size_meters, - use_h3: use_h3, - h3_resolution: h3_resolution - ).call - end - - def initialize(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS, use_h3: false, h3_resolution: 5) + def initialize(center_lng:, center_lat:, h3_resolution: 5) @center_lng = center_lng @center_lat = center_lat - @size_meters = size_meters - @use_h3 = use_h3 @h3_resolution = h3_resolution end def call - if use_h3 - generate_h3_hexagon_polygon - else - generate_hexagon_polygon - end + generate_h3_hexagon_polygon end private - attr_reader :center_lng, :center_lat, :size_meters, :use_h3, :h3_resolution + attr_reader :center_lng, :center_lat, :h3_resolution def generate_h3_hexagon_polygon # Convert coordinates to H3 format [lat, lng] @@ -45,7 +27,7 @@ module Maps boundary_coordinates = H3.to_boundary(h3_index) # Convert to GeoJSON polygon format (lng, lat) - polygon_coordinates = boundary_coordinates.map { |lat, lng| [lng, lat] } + polygon_coordinates = boundary_coordinates.map { [_2, _1] } # Close the polygon by adding the first point at the end polygon_coordinates << polygon_coordinates.first @@ -55,50 +37,5 @@ module Maps 'coordinates' => [polygon_coordinates] } end - - def generate_hexagon_polygon - # Generate hexagon vertices around center point - # For a regular hexagon: - # - Circumradius (center to vertex) = size_meters / 2 - # - This creates hexagons that are approximately size_meters wide - - radius_meters = size_meters / 2.0 - - # Convert meter radius to degrees - # 1 degree latitude ≈ 111,111 meters - # 1 degree longitude ≈ 111,111 * cos(latitude) meters at given latitude - 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) - # Start at 30 degrees to orient hexagon with flat top - angle = ((i * 60) + 30) * Math::PI / 180 - - # Calculate vertex position using proper geographic coordinate system - # longitude (x-axis) uses cosine, latitude (y-axis) uses sine - lng_offset = radius_lng_degrees * Math.cos(angle) - lat_offset = radius_lat_degrees * Math.sin(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 diff --git a/app/services/maps/hexagon_request_handler.rb b/app/services/maps/hexagon_request_handler.rb index 6f6a0e9b..d2b2f3cb 100644 --- a/app/services/maps/hexagon_request_handler.rb +++ b/app/services/maps/hexagon_request_handler.rb @@ -2,24 +2,20 @@ module Maps class HexagonRequestHandler - def initialize(params:, user: nil, context: nil) + def initialize(params:, user: nil, stat: nil, start_date: nil, end_date: nil) @params = params @user = user - @context = context + @stat = stat + @start_date = start_date + @end_date = end_date end def call - context = @context || resolve_context - # For authenticated users, we need to find the matching stat - stat = context[:stat] || find_matching_stat(context) + stat ||= find_matching_stat - # Use pre-calculated hexagon centers if stat - cached_result = Maps::HexagonCenterManager.call( - stat: stat, - user: context[:user] - ) + cached_result = Maps::HexagonCenterManager.new(stat:, user:).call return cached_result[:data] if cached_result&.dig(:success) end @@ -31,22 +27,22 @@ module Maps private - attr_reader :params, :user, :context + attr_reader :params, :user, :stat, :start_date, :end_date - def find_matching_stat(context) - return unless context[:user] && context[:start_date] + def find_matching_stat + return unless user && start_date # Parse the date to extract year and month - if context[:start_date].is_a?(String) - date = Date.parse(context[:start_date]) - elsif context[:start_date].is_a?(Time) - date = context[:start_date].to_date + if start_date.is_a?(String) + date = Date.parse(start_date) + elsif start_date.is_a?(Time) + date = start_date.to_date else return end # Find the stat for this user, year, and month - context[:user].stats.find_by(year: date.year, month: date.month) + user.stats.find_by(year: date.year, month: date.month) rescue Date::Error nil end diff --git a/spec/services/maps/hexagon_center_manager_spec.rb b/spec/services/maps/hexagon_center_manager_spec.rb index 8ddee03c..2912e28c 100644 --- a/spec/services/maps/hexagon_center_manager_spec.rb +++ b/spec/services/maps/hexagon_center_manager_spec.rb @@ -4,12 +4,7 @@ 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 + subject(:manage_centers) { described_class.new(stat:, user:).call } let(:user) { create(:user) } let(:target_user) { user } diff --git a/spec/services/maps/hexagon_polygon_generator_spec.rb b/spec/services/maps/hexagon_polygon_generator_spec.rb index ed4c2edb..662d42c2 100644 --- a/spec/services/maps/hexagon_polygon_generator_spec.rb +++ b/spec/services/maps/hexagon_polygon_generator_spec.rb @@ -5,19 +5,17 @@ require 'rails_helper' RSpec.describe Maps::HexagonPolygonGenerator do describe '.call' do subject(:generate_polygon) do - described_class.call( + described_class.new( center_lng: center_lng, - center_lat: center_lat, - size_meters: size_meters - ) + center_lat: center_lat + ).call end let(:center_lng) { -74.0 } let(:center_lat) { 40.7 } - let(:size_meters) { 1000 } - it 'returns a polygon geometry' do - result = generate_polygon + it 'returns a polygon geometry using H3' do + result = generate_h3_polygon expect(result['type']).to eq('Polygon') expect(result['coordinates']).to be_an(Array) @@ -25,7 +23,7 @@ RSpec.describe Maps::HexagonPolygonGenerator do end it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do - result = generate_polygon + result = generate_h3_polygon coordinates = result['coordinates'].first expect(coordinates.length).to eq(7) # 6 vertices + closing vertex @@ -33,7 +31,7 @@ RSpec.describe Maps::HexagonPolygonGenerator do end it 'generates unique vertices' do - result = generate_polygon + result = generate_h3_polygon coordinates = result['coordinates'].first # Remove the closing vertex for uniqueness check @@ -42,17 +40,9 @@ RSpec.describe Maps::HexagonPolygonGenerator do end it 'generates vertices around the center point' do - result = generate_polygon + result = generate_h3_polygon coordinates = result['coordinates'].first - # Check that not all vertices are the same as center (vertices should be distributed) - vertices_equal_to_center = coordinates[0..5].count do |vertex| - lng, lat = vertex - lng == center_lng && lat == center_lat - end - - expect(vertices_equal_to_center).to eq(0) # No vertex should be exactly at center - # Check that vertices have some variation in coordinates longitudes = coordinates[0..5].map { |vertex| vertex[0] } latitudes = coordinates[0..5].map { |vertex| vertex[1] } @@ -61,128 +51,13 @@ RSpec.describe Maps::HexagonPolygonGenerator do expect(latitudes.uniq.size).to be > 1 # Should have different latitudes 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 - - context 'with H3 enabled' do - subject(:generate_h3_polygon) do - described_class.call( - center_lng: center_lng, - center_lat: center_lat, - size_meters: size_meters, - use_h3: true - ) + context 'when H3 operations fail' do + before do + allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error') end - it 'returns a polygon geometry using H3' do - result = generate_h3_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_h3_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_h3_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_h3_polygon - coordinates = result['coordinates'].first - - # Check that vertices have some variation in coordinates - longitudes = coordinates[0..5].map { |vertex| vertex[0] } - latitudes = coordinates[0..5].map { |vertex| vertex[1] } - - expect(longitudes.uniq.size).to be > 1 # Should have different longitudes - expect(latitudes.uniq.size).to be > 1 # Should have different latitudes - end - - context 'when H3 operations fail' do - before do - allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error') - end - - it 'raises the H3 error' do - expect { generate_h3_polygon }.to raise_error(StandardError, 'H3 error') - end - end - - it 'produces different results than mathematical hexagon' do - h3_result = generate_h3_polygon - math_result = described_class.call( - center_lng: center_lng, - center_lat: center_lat, - size_meters: size_meters, - use_h3: false - ) - - # H3 and mathematical hexagons should generally be different - # (unless we're very unlucky with alignment) - expect(h3_result['coordinates']).not_to eq(math_result['coordinates']) - end - end - - context 'with use_h3 parameter variations' do - it 'defaults to mathematical hexagon when use_h3 is false' do - result_explicit_false = described_class.call( - center_lng: center_lng, - center_lat: center_lat, - use_h3: false - ) - - result_default = described_class.call( - center_lng: center_lng, - center_lat: center_lat - ) - - expect(result_explicit_false).to eq(result_default) + it 'raises the H3 error' do + expect { generate_h3_polygon }.to raise_error(StandardError, 'H3 error') end end diff --git a/spec/services/maps/hexagon_request_handler_spec.rb b/spec/services/maps/hexagon_request_handler_spec.rb index 8868c87f..1f6a17b0 100644 --- a/spec/services/maps/hexagon_request_handler_spec.rb +++ b/spec/services/maps/hexagon_request_handler_spec.rb @@ -7,22 +7,14 @@ RSpec.describe Maps::HexagonRequestHandler do subject(:handle_request) do described_class.new( params: params, - user: current_api_user + user: user, + stat: nil, + start_date: params[:start_date], + end_date: params[:end_date] ).call 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: {}) - - # Clean up database state to avoid conflicts - order matters due to foreign keys - Point.delete_all - Stat.delete_all - User.delete_all - end context 'with authenticated user but no pre-calculated data' do let(:params) do @@ -71,7 +63,6 @@ RSpec.describe Maps::HexagonRequestHandler do } ) end - let(:current_api_user) { nil } it 'returns pre-calculated hexagon data' do result = handle_request @@ -96,7 +87,6 @@ RSpec.describe Maps::HexagonRequestHandler do } ) end - let(:current_api_user) { nil } it 'returns empty feature collection when no pre-calculated centers' do result = handle_request @@ -124,7 +114,6 @@ RSpec.describe Maps::HexagonRequestHandler do } ) end - let(:current_api_user) { nil } before do # Mock successful recalculation @@ -143,22 +132,5 @@ RSpec.describe Maps::HexagonRequestHandler do expect(stat.reload.hexagon_centers).to eq([[-74.0, 40.7, 1_717_200_000, 1_717_203_600]]) end end - - context 'error handling' do - let(:params) do - ActionController::Parameters.new( - { - uuid: 'invalid-uuid' - } - ) - end - let(:current_api_user) { nil } - - it 'raises ActiveRecord::RecordNotFound for invalid UUID' do - expect { handle_request }.to raise_error( - ActiveRecord::RecordNotFound - ) - end - end end end