Extract logic to service

This commit is contained in:
Eugene Burmakin 2025-08-24 11:33:46 +02:00
parent 60705ac301
commit b0f3289435
3 changed files with 184 additions and 29 deletions

View file

@ -1,12 +1,10 @@
# frozen_string_literal: true
class Api::V1::Maps::HexagonsController < ApiController
skip_before_action :authenticate_api_key
before_action :validate_bbox_params
def index
service = Maps::HexagonGrid.new(bbox_params)
service = Maps::HexagonGrid.new(hexagon_params)
result = service.call
render json: result
@ -27,6 +25,14 @@ class Api::V1::Maps::HexagonsController < ApiController
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size)
end
def hexagon_params
bbox_params.merge(
user_id: current_api_user&.id,
start_date: params[:start_date],
end_date: params[:end_date]
)
end
def validate_bbox_params
required_params = %w[min_lon min_lat max_lon max_lat]
missing_params = required_params.select { |param| params[param].blank? }

View file

@ -182,6 +182,11 @@ export class HexagonGrid {
this.loadingController = new AbortController();
try {
// Get current date range from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at');
const endDate = urlParams.get('end_at');
const params = new URLSearchParams({
min_lon: bounds.getWest(),
min_lat: bounds.getSouth(),
@ -189,6 +194,10 @@ export class HexagonGrid {
max_lat: bounds.getNorth()
});
// Add date parameters if they exist
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const response = await fetch(`${this.options.apiEndpoint}&${params}`, {
signal: this.loadingController.signal,
headers: {
@ -241,13 +250,21 @@ export class HexagonGrid {
return;
}
// Calculate max point count for color scaling
const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
const geoJsonLayer = L.geoJSON(geojsonData, {
style: () => this.options.style,
style: (feature) => this.styleHexagonByData(feature, maxPoints),
onEachFeature: (feature, layer) => {
// Add popup with statistics
const props = feature.properties;
const popupContent = this.buildPopupContent(props);
layer.bindPopup(popupContent);
// Add hover effects
layer.on({
mouseover: (e) => this.onHexagonMouseOver(e),
mouseout: (e) => this.onHexagonMouseOut(e),
mouseover: (e) => this.onHexagonMouseOver(e, feature),
mouseout: (e) => this.onHexagonMouseOut(e, feature),
click: (e) => this.onHexagonClick(e, feature)
});
}
@ -256,6 +273,57 @@ export class HexagonGrid {
geoJsonLayer.addTo(this.hexagonLayer);
}
/**
* Style hexagon based on point density and other data
*/
styleHexagonByData(feature, maxPoints) {
const props = feature.properties;
const pointCount = props.point_count || 0;
// Calculate opacity based on point density (0.2 to 0.8)
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
// Calculate color based on density
let color = '#3388ff'; // Default blue
if (pointCount > maxPoints * 0.7) {
color = '#d73027'; // High density - red
} else if (pointCount > maxPoints * 0.4) {
color = '#fc8d59'; // Medium-high density - orange
} else if (pointCount > maxPoints * 0.2) {
color = '#fee08b'; // Medium density - yellow
} else {
color = '#91bfdb'; // Low density - light blue
}
return {
fillColor: color,
fillOpacity: opacity,
color: color,
weight: 1,
opacity: opacity + 0.2
};
}
/**
* Build popup content with hexagon statistics
*/
buildPopupContent(props) {
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
return `
<div style="font-size: 12px; line-height: 1.4;">
<h4 style="margin: 0 0 8px 0; color: #2c5aa0;">Hexagon Stats</h4>
<strong>Points:</strong> ${props.point_count || 0}<br>
<strong>Density:</strong> ${props.density || 0} pts/km²<br>
${props.avg_speed ? `<strong>Avg Speed:</strong> ${props.avg_speed} km/h<br>` : ''}
${props.avg_battery ? `<strong>Avg Battery:</strong> ${props.avg_battery}%<br>` : ''}
<strong>Date Range:</strong><br>
<small>${startDate} - ${endDate}</small>
</div>
`;
}
/**
* Handle hexagon mouseover event
*/

View file

@ -2,18 +2,18 @@
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
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
validates :min_lon, :max_lon, inclusion: { in: -180..180 }
validates :min_lat, :max_lat, inclusion: { in: -90..90 }
@ -24,10 +24,13 @@ class Maps::HexagonGrid
def initialize(params = {})
@min_lon = params[:min_lon].to_f
@min_lat = params[:min_lat].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
@user_id = params[:user_id]
@start_date = params[:start_date]
@end_date = params[:end_date]
end
def call
@ -70,23 +73,23 @@ class Maps::HexagonGrid
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
@ -95,43 +98,99 @@ class Maps::HexagonGrid
end
def build_hexagon_sql
user_filter = user_id ? "user_id = #{user_id}" : "1=1"
date_filter = build_date_filter
<<~SQL
WITH bbox_geom AS (
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
),
bbox_utm AS (
SELECT
SELECT
ST_Transform(geom, 3857) as geom_utm,
geom as geom_wgs84
FROM bbox_geom
),
user_points AS (
SELECT
lonlat::geometry as point_geom,
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
id,
timestamp
FROM points
WHERE #{user_filter}
#{date_filter}
AND ST_Intersects(
lonlat::geometry,
(SELECT geom FROM bbox_geom)
)
),
hex_grid AS (
SELECT
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
),
hexagons_with_points AS (
SELECT DISTINCT
hex_geom_utm,
hex_i,
hex_j
FROM hex_grid hg
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
),
hexagon_stats AS (
SELECT
hwp.hex_geom_utm,
hwp.hex_i,
hwp.hex_j,
COUNT(up.id) as point_count,
MIN(up.timestamp) as earliest_point,
MAX(up.timestamp) as latest_point
FROM hexagons_with_points hwp
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
)
SELECT
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)
)
point_count,
earliest_point,
latest_point,
row_number() OVER (ORDER BY point_count DESC) as id
FROM hexagon_stats
ORDER BY point_count DESC
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
SQL
end
def build_date_filter
return "" unless start_date || end_date
conditions = []
conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date
conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date
conditions.any? ? "AND #{conditions.join(' AND ')}" : ""
end
def execute_sql(sql)
ActiveRecord::Base.connection.execute(sql)
end
def format_hexagons(result)
total_points = 0
hexagons = result.map do |row|
point_count = row['point_count'].to_i
total_points += point_count
# Parse timestamps and format dates
earliest = row['earliest_point'] ? Time.at(row['earliest_point'].to_f).iso8601 : nil
latest = row['latest_point'] ? Time.at(row['latest_point'].to_f).iso8601 : nil
{
type: 'Feature',
id: row['id'],
@ -140,13 +199,17 @@ class Maps::HexagonGrid
hex_id: row['id'],
hex_i: row['hex_i'],
hex_j: row['hex_j'],
hex_size: hex_size
hex_size: hex_size,
point_count: point_count,
earliest_point: earliest,
latest_point: latest,
density: calculate_density(point_count)
}
}
end
Rails.logger.info "Generated #{hexagons.count} hexagons for area #{area_km2.round(2)} km²"
Rails.logger.info "Generated #{hexagons.count} hexagons containing #{total_points} points for area #{area_km2.round(2)} km²"
{
type: 'FeatureCollection',
features: hexagons,
@ -155,18 +218,36 @@ class Maps::HexagonGrid
area_km2: area_km2.round(2),
hex_size_m: hex_size,
count: hexagons.count,
estimated_count: estimated_hexagon_count
total_points: total_points,
user_id: user_id,
date_range: build_date_range_metadata
}
}
end
def calculate_density(point_count)
# Calculate points per km² for the hexagon
# A hexagon with radius 500m has area ≈ 0.65 km²
hexagon_area_km2 = 0.65 * (hex_size / 500.0) ** 2
(point_count / hexagon_area_km2).round(2)
end
def build_date_range_metadata
return nil unless start_date || end_date
{
start_date: start_date,
end_date: end_date
}
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
end