# 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