# frozen_string_literal: true class Maps::H3HexagonCenters include ActiveModel::Validations # H3 Configuration DEFAULT_H3_RESOLUTION = 8 # Small hexagons for good detail MAX_HEXAGONS = 10_000 # Maximum number of hexagons to prevent memory issues # Validation error classes class TooManyHexagonsError < StandardError; end class InvalidCoordinatesError < StandardError; end class PostGISError < StandardError; end attr_reader :user_id, :start_date, :end_date, :h3_resolution validates :user_id, presence: true def initialize(user_id:, start_date:, end_date:, h3_resolution: DEFAULT_H3_RESOLUTION) @user_id = user_id @start_date = start_date @end_date = end_date @h3_resolution = h3_resolution.clamp(0, 15) # Ensure valid H3 resolution end def call validate! points = fetch_user_points return [] if points.empty? h3_indexes_with_counts = calculate_h3_indexes(points) if h3_indexes_with_counts.size > MAX_HEXAGONS Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution" # Try with lower resolution (larger hexagons) return recalculate_with_lower_resolution(points) end Rails.logger.info "Generated #{h3_indexes_with_counts.size} H3 hexagons at resolution #{h3_resolution} for user #{user_id}" # Convert to format: [h3_index_string, point_count, earliest_timestamp, latest_timestamp] h3_indexes_with_counts.map do |h3_index, data| [ h3_index.to_s(16), # Store as hex string data[:count], data[:earliest], data[:latest] ] end rescue StandardError => e message = "Failed to calculate H3 hexagon centers: #{e.message}" ExceptionReporter.call(e, message) if defined?(ExceptionReporter) raise PostGISError, message end private def fetch_user_points start_timestamp = parse_date_to_timestamp(start_date) end_timestamp = parse_date_to_timestamp(end_date) Point.where(user_id: user_id) .where(timestamp: start_timestamp..end_timestamp) .where.not(lonlat: nil) .select(:id, :lonlat, :timestamp) end def calculate_h3_indexes(points) h3_data = Hash.new { |h, k| h[k] = { count: 0, earliest: nil, latest: nil } } points.find_each do |point| # Extract lat/lng from PostGIS point coordinates = [point.lonlat.y, point.lonlat.x] # [lat, lng] for H3 # Get H3 index for this point h3_index = H3.from_geo_coordinates(coordinates, h3_resolution) # Aggregate data for this hexagon data = h3_data[h3_index] data[:count] += 1 data[:earliest] = [data[:earliest], point.timestamp].compact.min data[:latest] = [data[:latest], point.timestamp].compact.max end h3_data end def recalculate_with_lower_resolution(points) # Try with resolution 2 levels lower (4x larger hexagons) lower_resolution = [h3_resolution - 2, 0].max Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}" service = self.class.new( user_id: user_id, start_date: start_date, end_date: end_date, h3_resolution: lower_resolution ) service.call end def parse_date_to_timestamp(date) case date when String if date.match?(/^\d+$/) date.to_i else Time.parse(date).to_i end when Integer date else Time.parse(date.to_s).to_i end rescue ArgumentError => e ExceptionReporter.call(e, "Invalid date format: #{date}") if defined?(ExceptionReporter) raise ArgumentError, "Invalid date format: #{date}" end def validate! return if valid? raise InvalidCoordinatesError, errors.full_messages.join(', ') end end