Precalculate hexagons for stats

This commit is contained in:
Eugene Burmakin 2025-09-13 23:11:42 +02:00
parent bab666b182
commit a2aa1be271
11 changed files with 204 additions and 52 deletions

File diff suppressed because one or more lines are too long

View file

@ -6,9 +6,26 @@ class Api::V1::Maps::HexagonsController < ApiController
before_action :set_user_and_dates
def index
service = Maps::HexagonGrid.new(hexagon_params)
result = service.call
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 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
end
end
# Fall back to on-the-fly calculation
Rails.logger.debug '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
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
@ -26,32 +43,8 @@ class Api::V1::Maps::HexagonsController < ApiController
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
# Convert dates to timestamps (handle both string and timestamp formats)
start_timestamp = case @start_date
when String
# Check if it's a numeric string (timestamp) or date string
if @start_date.match?(/^\d+$/)
@start_date.to_i
else
Time.parse(@start_date).to_i
end
when Integer
@start_date
else
@start_date.to_i
end
end_timestamp = case @end_date
when String
# Check if it's a numeric string (timestamp) or date string
if @end_date.match?(/^\d+$/)
@end_date.to_i
else
Time.parse(@end_date).to_i
end
when Integer
@end_date
else
@end_date.to_i
end
start_timestamp = coerce_date(@start_date)
end_timestamp = coerce_date(@end_date)
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
point_count = points_relation.count
@ -140,4 +133,20 @@ class Api::V1::Maps::HexagonsController < ApiController
error: "Missing required parameters: #{missing_params.join(', ')}"
}, status: :bad_request
end
def coerce_date(param)
case param
when String
# Check if it's a numeric string (timestamp) or date string
if param.match?(/^\d+$/)
param.to_i
else
Time.parse(param).to_i
end
when Integer
param
else
param.to_i
end
end
end

View file

@ -17,6 +17,7 @@ class Shared::StatsController < ApplicationController
@user = @stat.user
@is_public_view = true
@data_bounds = @stat.calculate_data_bounds
@hexagons_available = @stat.hexagons_available?
render 'stats/public_month'
end

View file

@ -10,6 +10,7 @@ export default class extends BaseController {
month: Number,
uuid: String,
dataBounds: Object,
hexagonsAvailable: Boolean,
selfHosted: String
};
@ -122,13 +123,16 @@ export default class extends BaseController {
this.map.off('moveend');
this.map.off('zoomend');
// Load hexagons only once on page load (static behavior)
// NOTE: Do NOT hide loading overlay here - let loadStaticHexagons() handle it
if (dataBounds && dataBounds.point_count > 0) {
// Load hexagons only if they are pre-calculated and data exists
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
await this.loadStaticHexagons();
} else {
console.warn('No data bounds or points available - not showing hexagons');
// Only hide loading indicator if no hexagons to load
if (!this.hexagonsAvailableValue) {
console.log('No pre-calculated hexagons available - skipping hexagon loading');
} else {
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');
if (loadingElement) {
loadingElement.style.display = 'none';

View file

@ -56,6 +56,10 @@ class Stat < ApplicationRecord
sharing_enabled? && !sharing_expired?
end
def hexagons_available?(hex_size = 1000)
hexagon_data&.dig(hex_size.to_s, 'geojson').present?
end
def generate_new_sharing_uuid!
update!(sharing_uuid: SecureRandom.uuid)
end

View file

@ -35,10 +35,7 @@ class HexagonQuery
SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
),
bbox_utm AS (
SELECT
ST_Transform(geom, 3857) as geom_utm,
geom as geom_wgs84
FROM bbox_geom
SELECT ST_Transform(geom, 3857) as geom_utm FROM bbox_geom
),
user_points AS (
SELECT
@ -49,25 +46,22 @@ class HexagonQuery
FROM points
WHERE #{user_sql}
#{date_filter}
AND ST_Intersects(
lonlat,
(SELECT geom FROM bbox_geom)::geometry
)
AND lonlat && (SELECT geom FROM bbox_geom)
),
hex_grid AS (
SELECT
(ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
(ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
(ST_HexagonGrid($5, bbox_utm.geom_utm)).j as hex_j
(ST_HexagonGrid($5, geom_utm)).geom as hex_geom_utm,
(ST_HexagonGrid($5, geom_utm)).i as hex_i,
(ST_HexagonGrid($5, geom_utm)).j as hex_j
FROM bbox_utm
),
hexagons_with_points AS (
SELECT DISTINCT
hex_geom_utm,
hex_i,
hex_j
hg.hex_geom_utm,
hg.hex_i,
hg.hex_j
FROM hex_grid hg
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
),
hexagon_stats AS (
SELECT
@ -78,7 +72,7 @@ class HexagonQuery
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)
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

View file

@ -0,0 +1,57 @@
# frozen_string_literal: true
class HexagonCacheService
def initialize(user:, stat: nil, start_date: nil, end_date: nil)
@user = user
@stat = stat
@start_date = start_date
@end_date = end_date
end
def available?(hex_size)
return false unless @user
return false unless hex_size.to_i == 1000
target_stat&.hexagons_available?(hex_size)
end
def cached_geojson(hex_size)
return nil unless target_stat
target_stat.hexagon_data.dig(hex_size.to_s, 'geojson')
rescue StandardError => e
Rails.logger.warn "Failed to retrieve cached hexagon data: #{e.message}"
nil
end
private
attr_reader :user, :stat, :start_date, :end_date
def target_stat
@target_stat ||= stat || find_monthly_stat
end
def find_monthly_stat
return nil unless start_date && end_date
begin
start_time = Time.zone.parse(start_date)
end_time = Time.zone.parse(end_date)
# Only use cached data for exact monthly requests
return nil unless monthly_date_range?(start_time, end_time)
user.stats.find_by(year: start_time.year, month: start_time.month)
rescue StandardError
nil
end
end
def monthly_date_range?(start_time, end_time)
start_time.beginning_of_month == start_time &&
end_time.end_of_month.beginning_of_day.to_date == end_time.to_date &&
start_time.month == end_time.month &&
start_time.year == end_time.year
end
end

View file

@ -37,7 +37,8 @@ class Stats::CalculateMonth
stat.assign_attributes(
daily_distance: distance_by_day,
distance: distance(distance_by_day),
toponyms: toponyms
toponyms: toponyms,
hexagon_data: calculate_hexagons
)
stat.save
end
@ -82,4 +83,77 @@ class Stats::CalculateMonth
def destroy_month_stats(year, month)
Stat.where(year:, month:, user:).destroy_all
end
def calculate_hexagons
return nil if points.empty?
# Calculate bounding box for the user's points in this month
bounds = calculate_data_bounds
return nil unless bounds
# Pre-calculate hexagons for 1000m size used across the system
hexagon_sizes = [1000] # 1000m hexagons for consistent visualization
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
end
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
def end_date_iso8601
DateTime.new(year, month, -1).end_of_day.iso8601
end
end

View file

@ -80,6 +80,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-self-hosted-value="<%= @self_hosted %>"></div>
<!-- Loading overlay -->

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddHexagonDataToStats < ActiveRecord::Migration[8.0]
def change
add_column :stats, :hexagon_data, :jsonb
end
end

3
db/schema.rb generated
View file

@ -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_10_224714) do
ActiveRecord::Schema[8.0].define(version: 2025_09_13_194134) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -222,6 +222,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
t.jsonb "daily_distance", default: {}
t.jsonb "sharing_settings", default: {}
t.uuid "sharing_uuid"
t.jsonb "hexagon_data"
t.index ["distance"], name: "index_stats_on_distance"
t.index ["month"], name: "index_stats_on_month"
t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true