mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Precalculate hexagons for stats
This commit is contained in:
parent
bab666b182
commit
a2aa1be271
11 changed files with 204 additions and 52 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
app/services/hexagon_cache_service.rb
Normal file
57
app/services/hexagon_cache_service.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
7
db/migrate/20250913194134_add_hexagon_data_to_stats.rb
Normal file
7
db/migrate/20250913194134_add_hexagon_data_to_stats.rb
Normal 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
3
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_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
|
||||
|
|
|
|||
Loading…
Reference in a new issue