mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Calculate only centers of hexagons
This commit is contained in:
parent
dc13bc1fd2
commit
6314442770
9 changed files with 518 additions and 99 deletions
|
|
@ -6,25 +6,31 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
before_action :set_user_and_dates
|
||||
|
||||
def index
|
||||
hex_size = bbox_params[:hex_size]&.to_f || 1000.0
|
||||
cache_service = HexagonCacheService.new(
|
||||
user: @target_user,
|
||||
stat: @stat,
|
||||
start_date: @start_date,
|
||||
end_date: @end_date
|
||||
)
|
||||
# Try to use pre-calculated hexagon centers from stats
|
||||
if @stat&.hexagon_centers.present?
|
||||
result = build_hexagons_from_centers(@stat.hexagon_centers)
|
||||
Rails.logger.debug "Using pre-calculated hexagon centers: #{@stat.hexagon_centers.size} centers"
|
||||
return render json: result
|
||||
end
|
||||
|
||||
# Try to use pre-calculated hexagon data if available
|
||||
if cache_service.available?(hex_size)
|
||||
cached_result = cache_service.cached_geojson(hex_size)
|
||||
if cached_result
|
||||
Rails.logger.debug 'Using cached hexagon data'
|
||||
return render json: cached_result
|
||||
# Handle legacy "area too large" entries - recalculate them now that we can handle large areas
|
||||
if @stat&.hexagon_centers&.dig('area_too_large')
|
||||
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{@stat.id}"
|
||||
|
||||
# Trigger recalculation
|
||||
service = Stats::CalculateMonth.new(@target_user.id, @stat.year, @stat.month)
|
||||
new_centers = service.send(:calculate_hexagon_centers)
|
||||
|
||||
if new_centers && !new_centers.dig(:area_too_large)
|
||||
@stat.update(hexagon_centers: new_centers)
|
||||
result = build_hexagons_from_centers(new_centers)
|
||||
Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
|
||||
return render json: result
|
||||
end
|
||||
end
|
||||
|
||||
# Fall back to on-the-fly calculation
|
||||
Rails.logger.debug 'Calculating hexagons on-the-fly'
|
||||
# Fall back to on-the-fly calculation for legacy/missing data
|
||||
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
|
||||
result = Maps::HexagonGrid.new(hexagon_params).call
|
||||
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
|
||||
render json: result
|
||||
|
|
@ -77,6 +83,66 @@ class Api::V1::Maps::HexagonsController < ApiController
|
|||
|
||||
private
|
||||
|
||||
def build_hexagons_from_centers(centers)
|
||||
# Convert stored centers back to hexagon polygons
|
||||
# Each center is [lng, lat, earliest_timestamp, latest_timestamp]
|
||||
hexagon_features = centers.map.with_index do |center, index|
|
||||
lng, lat, earliest, latest = center
|
||||
|
||||
# Generate hexagon polygon from center point (1000m hexagons)
|
||||
hexagon_geojson = generate_hexagon_polygon(lng, lat, 1000)
|
||||
|
||||
{
|
||||
type: 'Feature',
|
||||
id: index + 1,
|
||||
geometry: hexagon_geojson,
|
||||
properties: {
|
||||
hex_id: index + 1,
|
||||
hex_size: 1000,
|
||||
earliest_point: earliest ? Time.zone.at(earliest).iso8601 : nil,
|
||||
latest_point: latest ? Time.zone.at(latest).iso8601 : nil
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => hexagon_features,
|
||||
'metadata' => {
|
||||
'hex_size_m' => 1000,
|
||||
'count' => hexagon_features.count,
|
||||
'user_id' => @target_user.id,
|
||||
'pre_calculated' => true
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def generate_hexagon_polygon(center_lng, center_lat, size_meters)
|
||||
# Generate hexagon vertices around center point
|
||||
# This is a simplified hexagon generation - for production you might want more precise calculations
|
||||
earth_radius = 6_371_000 # meters
|
||||
angular_size = size_meters / earth_radius
|
||||
|
||||
vertices = []
|
||||
6.times do |i|
|
||||
angle = (i * 60) * Math::PI / 180 # 60 degrees between vertices
|
||||
|
||||
# Calculate offset in degrees (rough approximation)
|
||||
lat_offset = angular_size * Math.cos(angle) * 180 / Math::PI
|
||||
lng_offset = angular_size * Math.sin(angle) * 180 / Math::PI / Math.cos(center_lat * Math::PI / 180)
|
||||
|
||||
vertices << [center_lng + lng_offset, center_lat + lat_offset]
|
||||
end
|
||||
|
||||
# Close the polygon
|
||||
vertices << vertices.first
|
||||
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [vertices]
|
||||
}
|
||||
end
|
||||
|
||||
def bbox_params
|
||||
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,9 +23,7 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hexagonGrid) {
|
||||
this.hexagonGrid.destroy();
|
||||
}
|
||||
// No hexagonGrid to destroy for public sharing
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
|
|
@ -102,35 +100,24 @@ export default class extends BaseController {
|
|||
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
|
||||
}
|
||||
|
||||
this.hexagonGrid = createHexagonGrid(this.map, {
|
||||
apiEndpoint: '/api/v1/maps/hexagons',
|
||||
style: {
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3388ff',
|
||||
weight: 1,
|
||||
opacity: 0.7
|
||||
},
|
||||
debounceDelay: 300,
|
||||
maxZoom: 15,
|
||||
minZoom: 4
|
||||
});
|
||||
// Don't create hexagonGrid for public sharing - we handle hexagons manually
|
||||
// this.hexagonGrid = createHexagonGrid(this.map, {...});
|
||||
|
||||
// Force hide immediately after creation to prevent auto-showing
|
||||
this.hexagonGrid.hide();
|
||||
|
||||
// Disable all dynamic behavior by removing event listeners
|
||||
this.map.off('moveend');
|
||||
this.map.off('zoomend');
|
||||
console.log('🎯 Public sharing: skipping HexagonGrid creation, using manual loading');
|
||||
console.log('🔍 Debug values:');
|
||||
console.log(' dataBounds:', dataBounds);
|
||||
console.log(' point_count:', dataBounds?.point_count);
|
||||
console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue);
|
||||
console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue);
|
||||
|
||||
// Load hexagons only if they are pre-calculated and data exists
|
||||
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
|
||||
await this.loadStaticHexagons();
|
||||
} else {
|
||||
if (!this.hexagonsAvailableValue) {
|
||||
console.log('No pre-calculated hexagons available - skipping hexagon loading');
|
||||
console.log('📋 No pre-calculated hexagons available for public sharing - skipping hexagon loading');
|
||||
} else {
|
||||
console.warn('No data bounds or points available - not showing hexagons');
|
||||
console.warn('⚠️ No data bounds or points available - not showing hexagons');
|
||||
}
|
||||
// Hide loading indicator if no hexagons to load
|
||||
const loadingElement = document.getElementById('map-loading');
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ class Stat < ApplicationRecord
|
|||
end
|
||||
|
||||
def hexagons_available?(hex_size = 1000)
|
||||
# Check new optimized format (hexagon_centers) first
|
||||
return true if hexagon_centers.present? && hexagon_centers.is_a?(Array) && hexagon_centers.any?
|
||||
|
||||
# Fallback to legacy format (hexagon_data) for backwards compatibility
|
||||
hexagon_data&.dig(hex_size.to_s, 'geojson').present?
|
||||
end
|
||||
|
||||
|
|
|
|||
380
app/services/maps/hexagon_centers.rb
Normal file
380
app/services/maps/hexagon_centers.rb
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Maps::HexagonCenters
|
||||
include ActiveModel::Validations
|
||||
|
||||
# Constants for configuration
|
||||
HEX_SIZE = 1000 # meters - fixed 1000m hexagons
|
||||
MAX_AREA_KM2 = 10_000 # Maximum area for simple calculation
|
||||
TILE_SIZE_KM = 100 # Size of each tile for large area processing
|
||||
MAX_TILES = 100 # Maximum number of tiles to process
|
||||
|
||||
# Validation error classes
|
||||
class BoundingBoxTooLargeError < StandardError; end
|
||||
class InvalidCoordinatesError < StandardError; end
|
||||
class PostGISError < StandardError; end
|
||||
|
||||
attr_reader :user_id, :start_date, :end_date
|
||||
|
||||
validates :user_id, presence: true
|
||||
|
||||
def initialize(user_id:, start_date:, end_date:)
|
||||
@user_id = user_id
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
validate!
|
||||
|
||||
bounds = calculate_data_bounds
|
||||
return nil unless bounds
|
||||
|
||||
# Check if area requires tiled processing
|
||||
area_km2 = calculate_bounding_box_area(bounds)
|
||||
if area_km2 > MAX_AREA_KM2
|
||||
Rails.logger.info "Large area detected (#{area_km2.round} km²), using tiled processing for user #{user_id}"
|
||||
return calculate_hexagon_centers_tiled(bounds, area_km2)
|
||||
end
|
||||
|
||||
calculate_hexagon_centers_simple
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
message = "Failed to calculate hexagon centers: #{e.message}"
|
||||
ExceptionReporter.call(e, message)
|
||||
raise PostGISError, message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_data_bounds
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat,
|
||||
MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL",
|
||||
'hexagon_centers_bounds_query',
|
||||
[user_id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
|
||||
return nil unless bounds_result
|
||||
|
||||
{
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_bounding_box_area(bounds)
|
||||
width = (bounds[:max_lng] - bounds[:min_lng]).abs
|
||||
height = (bounds[:max_lat] - bounds[:min_lat]).abs
|
||||
|
||||
# Convert degrees to approximate kilometers
|
||||
avg_lat = (bounds[:min_lat] + bounds[:max_lat]) / 2
|
||||
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
|
||||
height_km = height * 111
|
||||
|
||||
width_km * height_km
|
||||
end
|
||||
|
||||
def calculate_hexagon_centers_simple
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
|
||||
sql = <<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_SetSRID(ST_Envelope(ST_Collect(lonlat::geometry)), 4326) as geom
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT ST_Transform(geom, 3857) as geom_utm FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid($4, geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid($4, geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid($4, geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hg.hex_geom_utm,
|
||||
hg.hex_i,
|
||||
hg.hex_j
|
||||
FROM hex_grid hg
|
||||
JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_centers AS (
|
||||
SELECT
|
||||
ST_Transform(ST_Centroid(hwp.hex_geom_utm), 4326) as center,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
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
|
||||
ST_X(center) as lng,
|
||||
ST_Y(center) as lat,
|
||||
earliest_point,
|
||||
latest_point
|
||||
FROM hexagon_centers
|
||||
ORDER BY earliest_point;
|
||||
SQL
|
||||
|
||||
result = ActiveRecord::Base.connection.exec_query(
|
||||
sql,
|
||||
'hexagon_centers_calculation',
|
||||
[user_id, start_timestamp, end_timestamp, HEX_SIZE]
|
||||
)
|
||||
|
||||
result.map do |row|
|
||||
[
|
||||
row['lng'].to_f,
|
||||
row['lat'].to_f,
|
||||
row['earliest_point']&.to_i,
|
||||
row['latest_point']&.to_i
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_hexagon_centers_tiled(bounds, area_km2)
|
||||
# Calculate optimal tile size based on area
|
||||
tiles = generate_tiles(bounds, area_km2)
|
||||
|
||||
if tiles.size > MAX_TILES
|
||||
Rails.logger.warn "Area too large even for tiling (#{tiles.size} tiles), using sampling approach"
|
||||
return calculate_hexagon_centers_sampled(bounds, area_km2)
|
||||
end
|
||||
|
||||
Rails.logger.info "Processing #{tiles.size} tiles for large area hexagon calculation"
|
||||
|
||||
all_centers = []
|
||||
tiles.each_with_index do |tile, index|
|
||||
Rails.logger.debug "Processing tile #{index + 1}/#{tiles.size}"
|
||||
|
||||
centers = calculate_hexagon_centers_for_tile(tile)
|
||||
all_centers.concat(centers) if centers.any?
|
||||
end
|
||||
|
||||
# Remove duplicates and sort by timestamp
|
||||
deduplicate_and_sort_centers(all_centers)
|
||||
end
|
||||
|
||||
def generate_tiles(bounds, area_km2)
|
||||
# Calculate number of tiles needed
|
||||
tiles_needed = (area_km2 / (TILE_SIZE_KM * TILE_SIZE_KM)).ceil
|
||||
tiles_per_side = Math.sqrt(tiles_needed).ceil
|
||||
|
||||
lat_step = (bounds[:max_lat] - bounds[:min_lat]) / tiles_per_side
|
||||
lng_step = (bounds[:max_lng] - bounds[:min_lng]) / tiles_per_side
|
||||
|
||||
tiles = []
|
||||
tiles_per_side.times do |i|
|
||||
tiles_per_side.times do |j|
|
||||
tile_bounds = {
|
||||
min_lat: bounds[:min_lat] + (i * lat_step),
|
||||
max_lat: bounds[:min_lat] + ((i + 1) * lat_step),
|
||||
min_lng: bounds[:min_lng] + (j * lng_step),
|
||||
max_lng: bounds[:min_lng] + ((j + 1) * lng_step)
|
||||
}
|
||||
tiles << tile_bounds
|
||||
end
|
||||
end
|
||||
|
||||
tiles
|
||||
end
|
||||
|
||||
def calculate_hexagon_centers_for_tile(tile_bounds)
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
|
||||
sql = <<~SQL
|
||||
WITH tile_bounds AS (
|
||||
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
|
||||
),
|
||||
tile_utm AS (
|
||||
SELECT ST_Transform(geom, 3857) as geom_utm FROM tile_bounds
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE user_id = $5
|
||||
AND timestamp BETWEEN $6 AND $7
|
||||
AND lonlat IS NOT NULL
|
||||
AND lonlat && (SELECT geom FROM tile_bounds)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid($8, geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid($8, geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid($8, geom_utm)).j as hex_j
|
||||
FROM tile_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hg.hex_geom_utm,
|
||||
hg.hex_i,
|
||||
hg.hex_j
|
||||
FROM hex_grid hg
|
||||
JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_centers AS (
|
||||
SELECT
|
||||
ST_Transform(ST_Centroid(hwp.hex_geom_utm), 4326) as center,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
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
|
||||
ST_X(center) as lng,
|
||||
ST_Y(center) as lat,
|
||||
earliest_point,
|
||||
latest_point
|
||||
FROM hexagon_centers;
|
||||
SQL
|
||||
|
||||
result = ActiveRecord::Base.connection.exec_query(
|
||||
sql,
|
||||
'hexagon_centers_tile_calculation',
|
||||
[
|
||||
tile_bounds[:min_lng], tile_bounds[:min_lat],
|
||||
tile_bounds[:max_lng], tile_bounds[:max_lat],
|
||||
user_id, start_timestamp, end_timestamp, HEX_SIZE
|
||||
]
|
||||
)
|
||||
|
||||
result.map do |row|
|
||||
[
|
||||
row['lng'].to_f,
|
||||
row['lat'].to_f,
|
||||
row['earliest_point']&.to_i,
|
||||
row['latest_point']&.to_i
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_hexagon_centers_sampled(bounds, area_km2)
|
||||
# For extremely large areas, use point density sampling
|
||||
Rails.logger.info "Using density-based sampling for extremely large area (#{area_km2.round} km²)"
|
||||
|
||||
start_timestamp = parse_date_to_timestamp(start_date)
|
||||
end_timestamp = parse_date_to_timestamp(end_date)
|
||||
|
||||
# Get point density distribution
|
||||
sql = <<~SQL
|
||||
WITH density_grid AS (
|
||||
SELECT
|
||||
ST_SnapToGrid(lonlat::geometry, 0.1) as grid_point,
|
||||
COUNT(*) as point_count,
|
||||
MIN(timestamp) as earliest,
|
||||
MAX(timestamp) as latest
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL
|
||||
GROUP BY ST_SnapToGrid(lonlat::geometry, 0.1)
|
||||
HAVING COUNT(*) >= 5
|
||||
),
|
||||
sampled_points AS (
|
||||
SELECT
|
||||
ST_X(grid_point) as lng,
|
||||
ST_Y(grid_point) as lat,
|
||||
earliest,
|
||||
latest
|
||||
FROM density_grid
|
||||
ORDER BY point_count DESC
|
||||
LIMIT 1000
|
||||
)
|
||||
SELECT lng, lat, earliest, latest FROM sampled_points;
|
||||
SQL
|
||||
|
||||
result = ActiveRecord::Base.connection.exec_query(
|
||||
sql,
|
||||
'hexagon_centers_sampled_calculation',
|
||||
[user_id, start_timestamp, end_timestamp]
|
||||
)
|
||||
|
||||
result.map do |row|
|
||||
[
|
||||
row['lng'].to_f,
|
||||
row['lat'].to_f,
|
||||
row['earliest']&.to_i,
|
||||
row['latest']&.to_i
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def deduplicate_and_sort_centers(centers)
|
||||
# Remove near-duplicate centers (within ~100m)
|
||||
precision = 3 # ~111m precision at equator
|
||||
unique_centers = {}
|
||||
|
||||
centers.each do |center|
|
||||
lng, lat, earliest, latest = center
|
||||
key = "#{lng.round(precision)},#{lat.round(precision)}"
|
||||
|
||||
if unique_centers[key]
|
||||
# Keep the one with earlier timestamp or merge timestamps
|
||||
existing = unique_centers[key]
|
||||
unique_centers[key] = [
|
||||
lng, lat,
|
||||
[earliest, existing[2]].compact.min,
|
||||
[latest, existing[3]].compact.max
|
||||
]
|
||||
else
|
||||
unique_centers[key] = center
|
||||
end
|
||||
end
|
||||
|
||||
unique_centers.values.sort_by { |center| center[2] || 0 }
|
||||
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}")
|
||||
raise ArgumentError, "Invalid date format: #{date}"
|
||||
end
|
||||
|
||||
def validate!
|
||||
return if valid?
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ class Stats::CalculateMonth
|
|||
daily_distance: distance_by_day,
|
||||
distance: distance(distance_by_day),
|
||||
toponyms: toponyms,
|
||||
hexagon_data: calculate_hexagons
|
||||
hexagon_centers: calculate_hexagon_centers
|
||||
)
|
||||
stat.save
|
||||
end
|
||||
|
|
@ -84,71 +84,39 @@ class Stats::CalculateMonth
|
|||
Stat.where(year:, month:, user:).destroy_all
|
||||
end
|
||||
|
||||
def calculate_hexagons
|
||||
def calculate_hexagon_centers
|
||||
return nil if points.empty?
|
||||
|
||||
# Calculate bounding box for the user's points in this month
|
||||
bounds = calculate_data_bounds
|
||||
return nil unless bounds
|
||||
begin
|
||||
service = Maps::HexagonCenters.new(
|
||||
user_id: user.id,
|
||||
start_date: start_date_iso8601,
|
||||
end_date: end_date_iso8601
|
||||
)
|
||||
|
||||
# Pre-calculate hexagons for 1000m size used across the system
|
||||
hexagon_sizes = [1000] # 1000m hexagons for consistent visualization
|
||||
result = service.call
|
||||
|
||||
hexagon_sizes.each_with_object({}) do |hex_size, result|
|
||||
begin
|
||||
service = Maps::HexagonGrid.new(
|
||||
min_lon: bounds[:min_lng],
|
||||
min_lat: bounds[:min_lat],
|
||||
max_lon: bounds[:max_lng],
|
||||
max_lat: bounds[:max_lat],
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: start_date_iso8601,
|
||||
end_date: end_date_iso8601
|
||||
)
|
||||
|
||||
geojson_result = service.call
|
||||
|
||||
# Store the complete GeoJSON result for instant serving
|
||||
result[hex_size.to_s] = {
|
||||
'geojson' => geojson_result,
|
||||
'bbox' => bounds,
|
||||
'generated_at' => Time.current.iso8601
|
||||
}
|
||||
|
||||
Rails.logger.info "Pre-calculated #{geojson_result['features']&.size || 0} hexagons (#{hex_size}m) for user #{user.id}, #{year}-#{month}"
|
||||
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
|
||||
Maps::HexagonGrid::InvalidCoordinatesError,
|
||||
Maps::HexagonGrid::PostGISError => e
|
||||
Rails.logger.warn "Hexagon calculation failed for user #{user.id}, #{year}-#{month}, size #{hex_size}m: #{e.message}"
|
||||
# Continue with other sizes even if one fails
|
||||
next
|
||||
if result.nil?
|
||||
Rails.logger.info "No hexagon centers calculated for user #{user.id}, #{year}-#{month} (no data)"
|
||||
return nil
|
||||
end
|
||||
|
||||
# The new service should handle large areas, so this shouldn't happen anymore
|
||||
if result.is_a?(Hash) && result[:area_too_large]
|
||||
Rails.logger.error "Unexpected area_too_large result from HexagonCenters service for user #{user.id}, #{year}-#{month}"
|
||||
return { area_too_large: true }
|
||||
end
|
||||
|
||||
Rails.logger.info "Pre-calculated #{result.size} hexagon centers for user #{user.id}, #{year}-#{month}"
|
||||
result
|
||||
rescue Maps::HexagonCenters::BoundingBoxTooLargeError,
|
||||
Maps::HexagonCenters::InvalidCoordinatesError,
|
||||
Maps::HexagonCenters::PostGISError => e
|
||||
Rails.logger.warn "Hexagon centers calculation failed for user #{user.id}, #{year}-#{month}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_data_bounds
|
||||
bounds_result = ActiveRecord::Base.connection.exec_query(
|
||||
"SELECT MIN(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat,
|
||||
MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng
|
||||
FROM points
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3
|
||||
AND lonlat IS NOT NULL",
|
||||
'hexagon_bounds_query',
|
||||
[user.id, start_timestamp, end_timestamp]
|
||||
).first
|
||||
|
||||
return nil unless bounds_result
|
||||
|
||||
{
|
||||
min_lat: bounds_result['min_lat'].to_f,
|
||||
max_lat: bounds_result['max_lat'].to_f,
|
||||
min_lng: bounds_result['min_lng'].to_f,
|
||||
max_lng: bounds_result['max_lng'].to_f
|
||||
}
|
||||
end
|
||||
|
||||
def start_date_iso8601
|
||||
DateTime.new(year, month, 1).beginning_of_day.iso8601
|
||||
end
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
data-public-stat-map-month-value="<%= @month %>"
|
||||
data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>"
|
||||
data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>"
|
||||
data-public-stat-map-hexagons-available-value="<%= @hexagons_available %>"
|
||||
data-public-stat-map-hexagons-available-value="<%= @hexagons_available.to_s %>"
|
||||
data-public-stat-map-self-hosted-value="<%= @self_hosted %>"></div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddHexagonCentersToStats < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :stats, :hexagon_centers, :jsonb
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
class AddIndexToHexagonCenters < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :stats, :hexagon_centers, using: :gin, where: "hexagon_centers IS NOT NULL", algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_13_194134) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_14_095157) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -223,7 +223,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_13_194134) do
|
|||
t.jsonb "sharing_settings", default: {}
|
||||
t.uuid "sharing_uuid"
|
||||
t.jsonb "hexagon_data"
|
||||
t.jsonb "hexagon_centers"
|
||||
t.index ["distance"], name: "index_stats_on_distance"
|
||||
t.index ["hexagon_centers"], name: "index_stats_on_hexagon_centers", where: "(hexagon_centers IS NOT NULL)", using: :gin
|
||||
t.index ["month"], name: "index_stats_on_month"
|
||||
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
|
||||
t.index ["user_id"], name: "index_stats_on_user_id"
|
||||
|
|
|
|||
Loading…
Reference in a new issue