dawarich/app/services/maps/hexagon_grid.rb
2025-08-24 11:08:56 +02:00

172 lines
No EOL
4.6 KiB
Ruby

# frozen_string_literal: true
class Maps::HexagonGrid
include ActiveModel::Validations
# Constants for configuration
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
MAX_HEXAGONS_PER_REQUEST = 5000
MAX_AREA_KM2 = 250_000 # 500km x 500km
# Validation error classes
class BoundingBoxTooLargeError < StandardError; end
class InvalidCoordinatesError < StandardError; end
class PostGISError < StandardError; end
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
validates :hex_size, numericality: { greater_than: 0 }
validate :validate_bbox_order
validate :validate_area_size
def initialize(params = {})
@min_lon = params[:min_lon].to_f
@min_lat = params[:min_lat].to_f
@max_lon = params[:max_lon].to_f
@max_lat = params[:max_lat].to_f
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
end
def call
validate!
generate_hexagons
end
def area_km2
@area_km2 ||= calculate_area_km2
end
def crosses_dateline?
min_lon > max_lon
end
def in_polar_region?
max_lat.abs > 85 || min_lat.abs > 85
end
def estimated_hexagon_count
# Rough estimation based on area
# A 500m radius hexagon covers approximately 0.65 km²
hexagon_area_km2 = 0.65 * (hex_size / 500.0) ** 2
(area_km2 / hexagon_area_km2).round
end
private
def validate_bbox_order
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
end
def validate_area_size
if area_km2 > MAX_AREA_KM2
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
end
end
def calculate_area_km2
width = (max_lon - min_lon).abs
height = (max_lat - min_lat).abs
# Convert degrees to approximate kilometers
# 1 degree latitude ≈ 111 km
# 1 degree longitude ≈ 111 km * cos(latitude)
avg_lat = (min_lat + max_lat) / 2
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
height_km = height * 111
width_km * height_km
end
def generate_hexagons
sql = build_hexagon_sql
Rails.logger.debug "Generating hexagons for bbox: #{[min_lon, min_lat, max_lon, max_lat]}"
Rails.logger.debug "Estimated hexagon count: #{estimated_hexagon_count}"
result = execute_sql(sql)
format_hexagons(result)
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error "PostGIS error generating hexagons: #{e.message}"
raise PostGISError, "Failed to generate hexagon grid: #{e.message}"
end
def build_hexagon_sql
<<~SQL
WITH bbox_geom AS (
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
),
bbox_utm AS (
SELECT
ST_Transform(geom, 3857) as geom_utm,
geom as geom_wgs84
FROM bbox_geom
),
hex_grid AS (
SELECT
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm,
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i,
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
FROM bbox_utm
)
SELECT
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
hex_i,
hex_j,
row_number() OVER (ORDER BY hex_i, hex_j) as id
FROM hex_grid
WHERE ST_Intersects(
hex_geom_utm,
(SELECT geom_utm FROM bbox_utm)
)
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
SQL
end
def execute_sql(sql)
ActiveRecord::Base.connection.execute(sql)
end
def format_hexagons(result)
hexagons = result.map do |row|
{
type: 'Feature',
id: row['id'],
geometry: JSON.parse(row['geojson']),
properties: {
hex_id: row['id'],
hex_i: row['hex_i'],
hex_j: row['hex_j'],
hex_size: hex_size
}
}
end
Rails.logger.info "Generated #{hexagons.count} hexagons for area #{area_km2.round(2)} km²"
{
type: 'FeatureCollection',
features: hexagons,
metadata: {
bbox: [min_lon, min_lat, max_lon, max_lat],
area_km2: area_km2.round(2),
hex_size_m: hex_size,
count: hexagons.count,
estimated_count: estimated_hexagon_count
}
}
end
def validate!
return if valid?
if area_km2 > MAX_AREA_KM2
raise BoundingBoxTooLargeError, errors.full_messages.join(', ')
end
raise InvalidCoordinatesError, errors.full_messages.join(', ')
end
end