From b0f32894354cceab2462f7bd388f78fb9d144f0f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 24 Aug 2025 11:33:46 +0200 Subject: [PATCH] Extract logic to service --- .../api/v1/maps/hexagons_controller.rb | 12 +- app/javascript/maps/hexagon_grid.js | 74 +++++++++- app/services/maps/hexagon_grid.rb | 127 ++++++++++++++---- 3 files changed, 184 insertions(+), 29 deletions(-) diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb index e117af0f..cf08dc93 100644 --- a/app/controllers/api/v1/maps/hexagons_controller.rb +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -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? } diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js index 9aa3623a..709121f8 100644 --- a/app/javascript/maps/hexagon_grid.js +++ b/app/javascript/maps/hexagon_grid.js @@ -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 ` +
+

Hexagon Stats

+ Points: ${props.point_count || 0}
+ Density: ${props.density || 0} pts/km²
+ ${props.avg_speed ? `Avg Speed: ${props.avg_speed} km/h
` : ''} + ${props.avg_battery ? `Avg Battery: ${props.avg_battery}%
` : ''} + Date Range:
+ ${startDate} - ${endDate} +
+ `; + } + /** * Handle hexagon mouseover event */ diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb index e6bd0538..29a5bc6d 100644 --- a/app/services/maps/hexagon_grid.rb +++ b/app/services/maps/hexagon_grid.rb @@ -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 \ No newline at end of file +end