Calculate only centers of hexagons

This commit is contained in:
Eugene Burmakin 2025-09-14 12:41:16 +02:00
parent dc13bc1fd2
commit 6314442770
9 changed files with 518 additions and 99 deletions

View file

@ -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

View file

@ -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');

View file

@ -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

View 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

View file

@ -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

View file

@ -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 -->

View file

@ -0,0 +1,5 @@
class AddHexagonCentersToStats < ActiveRecord::Migration[8.0]
def change
add_column :stats, :hexagon_centers, :jsonb
end
end

View file

@ -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
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_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"